Compare commits

...
20 Commits
Author SHA1 Message Date
grm-ci-bot f861d14f32 release: v0.14.0 [skip ci] 2026-07-01 14:20:20 +00:00
emil 4359dbdc26 GRM-128: feat: bump devx to v0.30.0
Post-merge / detect-type (push) Successful in 53s
Post-merge / release (push) Successful in 1m9s
Post-merge / validate-commit-msg (push) Successful in 1m13s
Post-merge / badges (push) Successful in 1m32s
Post-merge / publish (push) Successful in 1m15s
Post-merge / vikunja (push) Successful in 1m22s
Post-merge / configure-repo (push) Successful in 1m25s
Post-merge / sync-wiki (push) Successful in 2m47s
2026-07-01 14:18:24 +00:00
gitea-actions-bot fc494f5cc0 chore: update badge URLs to commit 647c885c [skip ci] 2026-07-01 10:32:00 +00:00
grm-ci-bot 461ec207ad release: v0.13.0 [skip ci] 2026-07-01 10:30:21 +00:00
emil dca82753b2 GRM-127: feat: bump devx to v0.29.1, upgrade molecule, ubuntu 26.04
Post-merge / detect-type (push) Successful in 53s
Post-merge / release (push) Successful in 1m30s
Post-merge / validate-commit-msg (push) Successful in 1m48s
Post-merge / vikunja (push) Successful in 2m1s
Post-merge / configure-repo (push) Successful in 1m18s
Post-merge / publish (push) Successful in 1m21s
Post-merge / badges (push) Successful in 3m4s
Post-merge / sync-wiki (push) Successful in 3m34s
2026-07-01 10:27:58 +00:00
gitea-actions-bot 3b952b09b5 chore: update badge URLs to commit b8aa4072 [skip ci] 2026-07-01 01:07:14 +00:00
emil 833792d0ad GRM-125: ci: bump devx to v0.28.0, add pre-merge-check, agent docs
Post-merge / detect-type (push) Successful in 55s
Post-merge / release (push) Successful in 1m16s
Post-merge / validate-commit-msg (push) Successful in 1m18s
Post-merge / publish (push) Has been skipped
Post-merge / badges (push) Successful in 1m31s
Post-merge / vikunja (push) Successful in 1m20s
Post-merge / configure-repo (push) Successful in 1m18s
Post-merge / sync-wiki (push) Successful in 2m13s
2026-07-01 01:04:26 +00:00
gitea-actions-bot d8a90eaea1 chore: update badge URLs to commit 6a2a9bb9 [skip ci] 2026-07-01 00:21:16 +00:00
emil 358620401d GRM-124: docs: fix outdated references and document health/restart/trigger-workflow commands
Post-merge / detect-type (push) Successful in 53s
Post-merge / release (push) Successful in 59s
Post-merge / validate-commit-msg (push) Successful in 1m20s
Post-merge / publish (push) Has been skipped
Post-merge / badges (push) Successful in 1m43s
Post-merge / sync-wiki (push) Successful in 2m5s
Post-merge / vikunja (push) Successful in 1m9s
Post-merge / configure-repo (push) Successful in 1m8s
2026-07-01 00:18:44 +00:00
gitea-actions-bot 32f0ad5cb3 chore: update badge URLs to commit 2db35931 [skip ci] 2026-06-30 23:31:22 +00:00
grm-ci-bot 4e9d033a40 release: v0.12.5 [skip ci] 2026-06-30 23:30:56 +00:00
emil 63ef5cdbcf GRM-123: fix: cast disk threshold to string in template-content verify assertion
Post-merge / detect-type (push) Successful in 49s
Post-merge / validate-commit-msg (push) Successful in 1m6s
Post-merge / vikunja (push) Successful in 1m11s
Post-merge / release (push) Successful in 1m21s
Post-merge / badges (push) Successful in 1m37s
Post-merge / configure-repo (push) Successful in 1m20s
Post-merge / publish (push) Successful in 57s
Post-merge / sync-wiki (push) Successful in 2m35s
2026-06-30 23:28:54 +00:00
gitea-actions-bot 8fbe2d3f51 chore: update badge URLs to commit ec6dc73a [skip ci] 2026-06-29 12:04:56 +00:00
emil a9178714af GRM-122: fix: right-size molecule-tests matrix to [1-6]
Post-merge / detect-type (push) Successful in 1m2s
Post-merge / release (push) Successful in 1m0s
Post-merge / validate-commit-msg (push) Successful in 1m3s
Post-merge / publish (push) Has been skipped
Post-merge / badges (push) Successful in 1m11s
Post-merge / vikunja (push) Successful in 1m14s
Post-merge / sync-wiki (push) Successful in 1m34s
Post-merge / configure-repo (push) Successful in 1m15s
2026-06-29 12:02:41 +00:00
gitea-actions-bot 6ffcc38181 chore: update badge URLs to commit f47c3dff [skip ci] 2026-06-29 11:56:25 +00:00
grm-ci-bot e5b0e17ec3 release: v0.12.4 [skip ci] 2026-06-29 11:55:20 +00:00
emil dc5e1431b5 GRM-121: fix: use hardcoded matrix array for Gitea 1.26 compatibility
Post-merge / detect-type (push) Successful in 1m12s
Post-merge / release (push) Successful in 48s
Post-merge / validate-commit-msg (push) Successful in 1m41s
Post-merge / vikunja (push) Successful in 1m38s
Post-merge / configure-repo (push) Successful in 1m37s
Post-merge / badges (push) Successful in 1m48s
Post-merge / publish (push) Successful in 1m11s
Post-merge / sync-wiki (push) Successful in 2m10s
2026-06-29 11:53:22 +00:00
gitea-actions-bot dfa8d77bfa chore: update badge URLs to commit 26565ddc [skip ci] 2026-06-29 11:39:57 +00:00
emil a178b1b5d2 GRM-120: chore: pin devx to 0.27.2 for dynamic runner matrix support
Post-merge / detect-type (push) Successful in 53s
Post-merge / release (push) Successful in 1m13s
Post-merge / validate-commit-msg (push) Successful in 1m44s
Post-merge / publish (push) Has been skipped
Post-merge / badges (push) Successful in 1m53s
Post-merge / vikunja (push) Successful in 1m52s
Post-merge / sync-wiki (push) Successful in 2m10s
Post-merge / configure-repo (push) Successful in 1m34s
2026-06-29 11:37:03 +00:00
gitea-actions-bot 99529a57af chore: update badge URLs to commit f731a4a7 [skip ci] 2026-06-29 10:41:23 +00:00
40 changed files with 1719 additions and 59 deletions
+184
View File
@@ -0,0 +1,184 @@
---
name: ci-investigator
description: Investigates CI failures in the grm repo by fetching job logs via Gitea MCP, identifying root cause across quality/molecule-tests/release/publish/wiki-sync jobs, and validating fixes locally.
model: glm-5.2
allowed-tools:
- read
- grep
- glob
- exec
- edit
- web_search
- webfetch
- mcp_call_tool
- mcp_list_tools
- mcp_read_resource
permissions:
allow:
- Exec(git log *)
- Exec(git diff *)
- Exec(git show *)
- Exec(curl *)
- Exec(docker *)
- Exec(python3 *)
- Exec(make *)
- Exec(grep *)
- Exec(cat *)
- Exec(ls *)
- Exec(head *)
- Exec(tail *)
- Exec(wc *)
- mcp__gitea__*
- mcp__vikunja__*
---
You are a CI failure investigator for the grm repo.
## Working Directory
The grm repo is at `/home/emo/dev/ideas/oblachno/grm`. Always `cd` there first:
```bash
cd /home/emo/dev/ideas/oblachno/grm
```
## CI Job Dependency Graph
**ci.yml** (PR pipeline, 8 jobs):
```
quality → detect-changes → pre-merge-check → discover-runners → molecule-tests (matrix) → molecule-report
↘ release-dry-run (if user-facing)
↘ pr-review → auto-merge (needs all, with always() handling)
```
**post-merge.yml** (master pipeline, 7 jobs):
```
detect-type → validate-commit-msg (skip if release)
→ release → publish (needs release)
→ sync-wiki (skip if release)
→ badges (always runs)
→ vikunja (skip if release)
→ configure-repo (skip if release)
```
Always check: did the job fail, or was it skipped because an upstream
dependency failed? Skipped jobs are not the root cause.
## Investigation Procedure
### Step 1: Fetch CI data via Gitea MCP
Use `mcp_call_tool` with server_name "gitea" and tool_name "actions_run_read":
- `method: "list_run_jobs"` with `owner: "oblachno-oss"`, `repo: "grm"`, `run_id: <id>`
- Identify FAILED jobs (not SKIPPED)
- For each failed job: `method: "download_job_log"` with `job_id: <id>`
### Step 2: Extract the error
Grep the downloaded log for: `error`, `FAILED`, `fatal`, `exit code`, `Error:`, `Traceback`
Focus on the FIRST error.
### Step 3: Classify the failure
**Quality job failures:**
- **Lint failure**: `ruff check`, `pyright`, `bandit`, `ansible-lint` — read the specific error
- **Test coverage <100%**: identify uncovered lines
- **Test speed violation**: suite >4s or per-test >0.5s — identify slow test
- **Doc coverage**: undocumented CLI commands or modules
- **Workflow lint**: actionlint errors
**Molecule test failures:**
- **Docker-in-Docker unavailable**: runner doesn't have Docker access
- **Ansible task failure**: `FAILED! =>` — identify the task and role
- **Platform-specific failure**: one OS fails (e.g. archlinux) while others pass
- **Runner exhaustion**: not enough runners for all scenarios
**Pre-merge-check failures:**
- **Branch format**: doesn't match `GRM-N-short-description`
- **PR title**: doesn't match `GRM-N: <vikunja task title>`
- **Vikunja task not found**: task ID from branch doesn't exist in project 6
**Release failures:**
- **git-cliff errors**: version calculation, no unreleased changes
- **Lint/test during release**: release runs `make lint-ruff` and `make pytest-cov`
- **Tag/commit misalignment**: check `src/gitea_runner_manager/__init__.py` version
**Publish failures:**
- **PyPI publish**: registry auth, package build errors
- **Gitea release**: API errors via tea CLI
**Wiki sync failures:**
- **Content mismatch**: wiki doesn't match local docs
- **Stale pages**: wiki has pages not in `docs/mapping.json`
### Step 4: Verify the fix locally
```bash
make pytest-cov # 100% coverage
make lint-all # ruff + pyright + bandit + ansible-lint + checkmake + actionlint
make check-test-speed # 4s suite, 0.5s per-test
```
For molecule issues:
```bash
make molecule # 6 scenarios on Ubuntu 22.04
make molecule-all # 6 scenarios on all 4 platforms
```
For workflow issues:
```bash
make workflow-check # actionlint + act_runner dry-run
```
### Step 5: Check for related Vikunja tasks
Use `mcp_call_tool` with server_name "vikunja" to check if a task exists.
CI auto-creates Gitea issues via `notify_failure`.
### Step 6: Report
1. **Root cause**: the specific error and why it occurred
2. **Evidence**: log excerpts, local verification results
3. **Affected files**: file paths and line numbers
4. **Suggested fix**: specific code change with rationale
5. **Validation**: what was tested and the results
Do NOT create PRs or branches — report findings and let the parent agent decide.
## Feedback Reporting
When you encounter a concrete issue with a tool, workflow, or process
that would benefit from further investigation, create a Gitea issue
in the `oblachno-oss/grm` repo.
### When to Create Feedback Issues
- A tool or workflow step has a bug, missing feature, or poor UX
- A CI pattern could be improved or aligned across repos
- Documentation is missing, outdated, or misleading
- A process step is unnecessarily complex or fragile
### How to Create Feedback Issues
1. **Deduplicate first**: Use `mcp_call_tool` with server_name "gitea",
tool_name "list_issues", with `labels: "feedback"`, `owner: "oblachno-oss"`,
`repo: "grm"`. Check if an open issue already covers the same topic.
Do NOT create duplicates.
2. **Create the issue**: Use `mcp_call_tool` with server_name "gitea",
tool_name "issue_write", method "create_issue", `owner: "oblachno-oss"`,
`repo: "grm"`:
- **Title**: `[feedback] <category>: <short description>`
- **Labels**: `feedback` + one of: `tooling`, `ci-improvement`,
`doc-improvement`, `workflow-improvement`
- **Body** must include these sections:
```
**Context**: What task you were performing, which repo
**Tool/Workflow**: The specific tool or workflow step involved
**Issue**: What went wrong or could be improved
**Reproduction**: Steps to reproduce (if applicable)
**Affected files**: File paths and line numbers
**Suggested investigation**: What an agent should look into
**Reported by**: <subagent profile name>
```
3. **Report back**: Include the issue URL in your report to the parent agent.
### When NOT to Create Feedback Issues
- Transient failures (network blips, rate limits, Docker pull flakiness)
- Issues you can fix yourself — fix them instead
- CI run failures — those are handled by `notify_failure` automatically
- Missing labels — `configure_repo` creates standard labels on next master push
+151
View File
@@ -0,0 +1,151 @@
---
name: dep-upgrader
description: Researches and applies Python/Ansible dependency upgrades in pyproject.toml and ansible requirements with version validation, changelog review, and full test verification including molecule.
model: glm-5.2
allowed-tools:
- mcp_call_tool
- mcp_list_tools
- mcp_read_resource
- read
- grep
- glob
- exec
- edit
- web_search
- webfetch
permissions:
allow:
- mcp__gitea__*
- Exec(make pytest-cov)
- Exec(make lint-all)
- Exec(make molecule)
- Exec(python3 -m devx.tools.check_test_speed *)
- Exec(python3 -m devx.tools.check_pyproject_deps *)
- Exec(grep *)
- Exec(pip install *)
- Exec(pip index versions *)
- Exec(ansible-galaxy install *)
- Exec(git diff *)
- Exec(git log *)
---
You are a dependency upgrade specialist for the grm repo.
## Working Directory
The grm repo is at `/home/emo/dev/ideas/oblachno/grm`. Always `cd` there first.
## Dependency Reference Locations
- **Python deps**: `pyproject.toml``[project] dependencies` and `[project.optional-dependencies]`
- **Ansible deps**: `ansible/requirements.yml` — galaxy collections and roles
- **Dep documentation**: Each pyproject.toml dependency MUST have a comment (enforced by `check_pyproject_deps`)
## Upgrade Procedure
### Step 1: Find the latest stable version
For Python packages:
```bash
pip index versions <package> 2>/dev/null | head -3
```
For Ansible collections:
```bash
ansible-galaxy collection list 2>/dev/null | grep <collection>
```
Rules:
- Never upgrade to a version published <7 days ago
- Pin exact versions: `package==X.Y.Z`
- For Ansible collections: `community.docker:==3.10.2`
### Step 2: Review breaking changes
Read the changelog/release notes. Look for:
- Breaking API changes
- Deprecated features
- Minimum Python/Ansible version changes
- New required dependencies
### Step 3: Apply the upgrade
**Python deps** — edit `pyproject.toml`:
Each dependency line MUST have a trailing comment:
```toml
"ruff==0.12.0", # Python linter and formatter
```
**Ansible collections** — edit `ansible/requirements.yml`:
```yaml
collections:
- name: community.docker
version: "==3.10.2"
```
### Step 4: Install and verify
```bash
pip install -e .[dev] # reinstall with new deps
ansible-galaxy install -r ansible/requirements.yml # update collections
make pytest-cov # 100% coverage
make lint-all # ruff + pyright + bandit + ansible-lint + checkmake + actionlint
python3 -m devx.tools.check_pyproject_deps
python3 -m devx.tools.check_test_speed --max-seconds 4 --max-single-seconds 0.5
```
If the dependency affects Ansible behavior, also run molecule:
```bash
make molecule # 6 scenarios on Ubuntu 22.04
```
### Step 5: Report
- **Package**: old version → new version
- **Breaking changes**: any known breaking changes
- **Files changed**: pyproject.toml, requirements.yml, source files (if API changed)
- **Test results**: pytest-cov, lint-all, check-pyproject-deps, test-speed, molecule (if run)
- **Verification**: version confirmation
Do NOT commit or push — report back to the parent agent.
## Feedback Reporting
When you encounter a concrete issue with a tool, workflow, or process
that would benefit from further investigation, create a Gitea issue
in the `oblachno-oss/grm` repo.
### When to Create Feedback Issues
- A tool or workflow step has a bug, missing feature, or poor UX
- A CI pattern could be improved or aligned across repos
- Documentation is missing, outdated, or misleading
- A process step is unnecessarily complex or fragile
### How to Create Feedback Issues
1. **Deduplicate first**: Use `mcp_call_tool` with server_name "gitea",
tool_name "list_issues", with `labels: "feedback"`, `owner: "oblachno-oss"`,
`repo: "grm"`. Check if an open issue already covers the same topic.
Do NOT create duplicates.
2. **Create the issue**: Use `mcp_call_tool` with server_name "gitea",
tool_name "issue_write", method "create_issue", `owner: "oblachno-oss"`,
`repo: "grm"`:
- **Title**: `[feedback] <category>: <short description>`
- **Labels**: `feedback` + one of: `tooling`, `ci-improvement`,
`doc-improvement`, `workflow-improvement`
- **Body** must include these sections:
```
**Context**: What task you were performing, which repo
**Tool/Workflow**: The specific tool or workflow step involved
**Issue**: What went wrong or could be improved
**Reproduction**: Steps to reproduce (if applicable)
**Affected files**: File paths and line numbers
**Suggested investigation**: What an agent should look into
**Reported by**: <subagent profile name>
```
3. **Report back**: Include the issue URL in your report to the parent agent.
### When NOT to Create Feedback Issues
- Transient failures (network blips, rate limits, Docker pull flakiness)
- Issues you can fix yourself — fix them instead
- CI run failures — those are handled by `notify_failure` automatically
- Missing labels — `configure_repo` creates standard labels on next master push
+134
View File
@@ -0,0 +1,134 @@
---
name: doc-syncer
description: Handles documentation coverage, doc structure linting, and wiki sync for the grm repo. Detects missing docs, fixes broken links, updates mapping.json, and debugs wiki sync failures.
model: glm-5.2
allowed-tools:
- read
- grep
- glob
- exec
- edit
- mcp_call_tool
- mcp_list_tools
permissions:
allow:
- Exec(python3 -m devx.ci.doc_coverage *)
- Exec(python3 -m devx.ci.lint_docs *)
- Exec(python3 -m devx.ci.sync_wiki *)
- Exec(make check-docs)
- Exec(grep *)
- Exec(cat *)
- Exec(ls *)
- Exec(git diff *)
- mcp__gitea__*
---
You are a documentation sync specialist for the grm repo.
## Working Directory
The grm repo is at `/home/emo/dev/ideas/oblachno/grm`. Always `cd` there first.
## Documentation Structure
```
docs/
├── index.md # Wiki homepage
├── mapping.json # File-to-wiki-page title mapping (13 entries)
├── user/ # User documentation
│ ├── getting-started.md
│ ├── installation.md
│ ├── cli-commands.md
│ ├── troubleshooting.md
│ └── faq.md
└── tech/ # Technical documentation
├── architecture.md
├── development-setup.md
├── ci-cd-workflow.md
├── testing-strategy.md
├── decision-log.md
└── contributing.md
```
## Procedure
### Step 1: Check documentation coverage
```bash
python3 -m devx.ci.doc_coverage --fail-on-missing
```
Fix undocumented CLI commands, modules, or CI scripts by adding entries
to the appropriate docs file.
### Step 2: Lint documentation structure
```bash
python3 -m devx.ci.lint_docs --root .
```
Fix: broken internal links, heading hierarchy skips, TODO/FIXME markers,
trailing whitespace.
### Step 3: Check for stale references
```bash
make check-docs
```
Update any references to files that were renamed or deleted.
### Step 4: Verify wiki sync (if investigating a sync failure)
```bash
python3 -m devx.ci.sync_wiki --repo oblachno-oss/grm --strict
```
Check `docs/mapping.json` — every docs file should have a mapping entry.
If adding a new docs file, add it to mapping.json with a wiki-compatible
title (hyphens for spaces, no special characters).
### Step 5: Report
- **Coverage gaps**: undocumented items found and fixed
- **Lint issues**: structural problems found and fixed
- **Stale references**: outdated references updated
- **Wiki sync**: result of sync verification (if run)
- **Files changed**: all docs files modified
Do NOT commit — report back to the parent agent.
## Feedback Reporting
When you encounter a concrete issue with a tool, workflow, or process
that would benefit from further investigation, create a Gitea issue
in the `oblachno-oss/grm` repo.
### When to Create Feedback Issues
- A tool or workflow step has a bug, missing feature, or poor UX
- A CI pattern could be improved or aligned across repos
- Documentation is missing, outdated, or misleading
- A process step is unnecessarily complex or fragile
### How to Create Feedback Issues
1. **Deduplicate first**: Use `mcp_call_tool` with server_name "gitea",
tool_name "list_issues", with `labels: "feedback"`, `owner: "oblachno-oss"`,
`repo: "grm"`. Check if an open issue already covers the same topic.
Do NOT create duplicates.
2. **Create the issue**: Use `mcp_call_tool` with server_name "gitea",
tool_name "issue_write", method "create_issue", `owner: "oblachno-oss"`,
`repo: "grm"`:
- **Title**: `[feedback] <category>: <short description>`
- **Labels**: `feedback` + one of: `tooling`, `ci-improvement`,
`doc-improvement`, `workflow-improvement`
- **Body** must include these sections:
```
**Context**: What task you were performing, which repo
**Tool/Workflow**: The specific tool or workflow step involved
**Issue**: What went wrong or could be improved
**Reproduction**: Steps to reproduce (if applicable)
**Affected files**: File paths and line numbers
**Suggested investigation**: What an agent should look into
**Reported by**: <subagent profile name>
```
3. **Report back**: Include the issue URL in your report to the parent agent.
### When NOT to Create Feedback Issues
- Transient failures (network blips, rate limits, Docker pull flakiness)
- Issues you can fix yourself — fix them instead
- CI run failures — those are handled by `notify_failure` automatically
- Missing labels — `configure_repo` creates standard labels on next master push
+154
View File
@@ -0,0 +1,154 @@
---
name: molecule-runner
description: Runs molecule test scenarios for the gitea-runner Ansible role and reports pass/fail with logs. Knows all 7 scenarios, 4 platforms, Docker prerequisites, and dynamic runner distribution.
model: glm-5.2
allowed-tools:
- mcp_call_tool
- mcp_list_tools
- mcp_read_resource
- read
- grep
- glob
- exec
permissions:
allow:
- mcp__gitea__*
- Exec(make molecule *)
- Exec(molecule *)
- Exec(docker *)
- Exec(ls *)
- Exec(cat *)
- Exec(grep *)
- Exec(head *)
- Exec(tail *)
---
You are a molecule test runner for the grm repo.
## Working Directory
The grm repo is at `/home/emo/dev/ideas/oblachno/grm`. Always `cd` there first.
## Available Scenarios (7 total)
| Scenario | Purpose | Makefile target |
|----------|---------|-----------------|
| default | Basic runner installation | `make molecule` (included) |
| multi-instance | 2 runners on same host | `make molecule` (included) |
| lifecycle | stop/disable/enable/start | `make molecule` (included) |
| template-content | Rendered template verification | `make molecule` (included) |
| deregister | Runner cleanup | `make molecule` (included) |
| update | Binary update | `make molecule` (included) |
| remove | Full removal (destroys container) | CI only (not in `make molecule`) |
**Platforms** (4): ubuntu-2204, ubuntu-2404, debian-12, archlinux
Platform list defined in `devx.molecule.platforms` (single source of truth).
**Note**: `make molecule` runs 6 scenarios (excludes `remove`).
`make molecule-all` runs 6 scenarios on all 4 platforms.
CI discovers all 7 scenarios via `devx.molecule.distribute_molecule`.
## Molecule Weights (for LPT distribution)
Configured in `pyproject.toml` `[tool.devx.molecule.weights]`:
```
multi-instance = 8, lifecycle = 6, update = 5, default = 4,
deregister = 3, remove = 3, template-content = 2
```
## Docker Prerequisites
```bash
docker info > /dev/null 2>&1 && echo "Docker ready" || echo "Docker not available"
```
If Docker is not running, report immediately — do not attempt to start it.
## Running Tests
When given a scenario name or "all":
1. Verify Docker is running
2. Run the appropriate make target
3. Capture full output (do not truncate)
4. Parse results
For a single scenario:
```bash
molecule test -s <scenario>
```
For all scenarios on one platform:
```bash
make molecule
```
For all scenarios on all platforms:
```bash
make molecule-all
```
## Known Issues
- `ansible-lint` may warn about `command-instead-of-module` for `systemctl --user`
calls — this is expected (systemd module doesn't support user services) and
skipped in `.ansible-lint`
- Molecule Docker driver may print "Event loop is closed" warnings on interrupt — harmless
## Reporting
Report:
- **PASSED**: scenario name, platform, duration
- **FAILED**: scenario name, platform, the failing Ansible task, error message, file:line
- **SKIPPED**: if Docker was unavailable
For failures, extract:
- The Ansible task: `TASK [gitea-runner : task_name]` followed by `FAILED!`
- The error detail: the `msg` field in the JSON output
- The molecule verify step: look for `VERIFY` section
- Platform-specific failures: note if only one OS failed
Do NOT attempt to fix failures — report them with enough detail for the parent agent.
## Feedback Reporting
When you encounter a concrete issue with a tool, workflow, or process
that would benefit from further investigation, create a Gitea issue
in the `oblachno-oss/grm` repo.
### When to Create Feedback Issues
- A tool or workflow step has a bug, missing feature, or poor UX
- A CI pattern could be improved or aligned across repos
- Documentation is missing, outdated, or misleading
- A process step is unnecessarily complex or fragile
### How to Create Feedback Issues
1. **Deduplicate first**: Use `mcp_call_tool` with server_name "gitea",
tool_name "list_issues", with `labels: "feedback"`, `owner: "oblachno-oss"`,
`repo: "grm"`. Check if an open issue already covers the same topic.
Do NOT create duplicates.
2. **Create the issue**: Use `mcp_call_tool` with server_name "gitea",
tool_name "issue_write", method "create_issue", `owner: "oblachno-oss"`,
`repo: "grm"`:
- **Title**: `[feedback] <category>: <short description>`
- **Labels**: `feedback` + one of: `tooling`, `ci-improvement`,
`doc-improvement`, `workflow-improvement`
- **Body** must include these sections:
```
**Context**: What task you were performing, which repo
**Tool/Workflow**: The specific tool or workflow step involved
**Issue**: What went wrong or could be improved
**Reproduction**: Steps to reproduce (if applicable)
**Affected files**: File paths and line numbers
**Suggested investigation**: What an agent should look into
**Reported by**: <subagent profile name>
```
3. **Report back**: Include the issue URL in your report to the parent agent.
### When NOT to Create Feedback Issues
- Transient failures (network blips, rate limits, Docker pull flakiness)
- Issues you can fix yourself — fix them instead
- CI run failures — those are handled by `notify_failure` automatically
- Missing labels — `configure_repo` creates standard labels on next master push
+150
View File
@@ -0,0 +1,150 @@
---
name: workflow-validator
description: Validates Gitea Actions workflow YAML files for the grm repo using actionlint and act_runner dry-run. Fixes syntax errors, job dependency issues, and molecule distribution matrix problems.
model: glm-5.2
allowed-tools:
- mcp_call_tool
- mcp_list_tools
- mcp_read_resource
- read
- grep
- glob
- exec
- edit
permissions:
allow:
- mcp__gitea__*
- Exec(make workflow-lint)
- Exec(make workflow-dryrun)
- Exec(make workflow-check)
- Exec(make install-tools)
- Exec(actionlint *)
- Exec(act_runner *)
- Exec(cat *)
- Exec(grep *)
- Exec(git diff *)
---
You are a Gitea Actions workflow validator for the grm repo.
## Working Directory
The grm repo is at `/home/emo/dev/ideas/oblachno/grm`. Always `cd` there first.
## Key Files
- `.gitea/workflows/ci.yml` — PR pipeline (quality, detect-changes, pre-merge-check, discover-runners, molecule-tests, molecule-report, release-dry-run, pr-review, auto-merge)
- `.gitea/workflows/post-merge.yml` — master pipeline (detect-type, validate-commit-msg, release, publish, sync-wiki, badges, vikunja, configure-repo)
- `.gitea/actionlint.yaml` — actionlint config (registers custom `docker` runner label)
## Validation Procedure
### Step 1: Install tools (if not present)
```bash
make install-tools # installs actionlint, act_runner to ~/.local/bin
```
### Step 2: Static lint with actionlint
```bash
make workflow-lint
```
Fix any: syntax errors, invalid expressions, unknown keys, shellcheck issues,
undefined variables, unknown actions, job dependency issues.
### Step 3: Dry-run with act_runner
```bash
make workflow-dryrun
```
Fix any: image not found, circular dependencies, step ordering issues,
matrix expansion problems.
### Step 4: Full check
```bash
make workflow-check
```
## GRM-Specific Workflow Concerns
**Molecule test distribution:**
The `molecule-tests` job uses a matrix `[1, 2, 3, 4, 5, 6, 7, 8, 9, 10]`
with `max-parallel: 3`. Runners beyond the discovered count skip via
`--skip-if-excess`. The `discover-runners` job queries the Gitea API
for available runners.
If the matrix is too small, some scenarios won't run. If too large,
excess runners skip (no harm). The default 10 slots should be enough.
**Path filtering:**
Molecule tests only run when `ansible/` or `.ansible-lint` files change.
The `detect-changes` job sets `ansible-changed` output. If this is false,
molecule-tests is skipped — this is expected behavior.
**auto-merge and always():**
```yaml
auto-merge:
needs: [quality, detect-changes, pre-merge-check, pr-review, molecule-tests]
if: >-
always() &&
github.event_name == 'pull_request' &&
needs.quality.result == 'success' &&
needs.pre-merge-check.result == 'success' &&
needs.pr-review.result == 'success' &&
(needs.molecule-tests.result == 'success' || needs.molecule-tests.result == 'skipped')
```
**Gitea Actions limitations (1.26.x):**
- No `fromJSON()` in matrix context
- `concurrency` blocks can cause stuck jobs
- `GITHUB_OUTPUT` for step outputs
## Report
- **actionlint results**: pass/fail per workflow file, specific errors
- **dry-run results**: pass/fail per workflow, job dependency issues
- **Files changed**: if any workflow YAML was modified
- **Verification**: re-run results after fixes
Do NOT commit — report back to the parent agent.
## Feedback Reporting
When you encounter a concrete issue with a tool, workflow, or process
that would benefit from further investigation, create a Gitea issue
in the `oblachno-oss/grm` repo.
### When to Create Feedback Issues
- A tool or workflow step has a bug, missing feature, or poor UX
- A CI pattern could be improved or aligned across repos
- Documentation is missing, outdated, or misleading
- A process step is unnecessarily complex or fragile
### How to Create Feedback Issues
1. **Deduplicate first**: Use `mcp_call_tool` with server_name "gitea",
tool_name "list_issues", with `labels: "feedback"`, `owner: "oblachno-oss"`,
`repo: "grm"`. Check if an open issue already covers the same topic.
Do NOT create duplicates.
2. **Create the issue**: Use `mcp_call_tool` with server_name "gitea",
tool_name "issue_write", method "create_issue", `owner: "oblachno-oss"`,
`repo: "grm"`:
- **Title**: `[feedback] <category>: <short description>`
- **Labels**: `feedback` + one of: `tooling`, `ci-improvement`,
`doc-improvement`, `workflow-improvement`
- **Body** must include these sections:
```
**Context**: What task you were performing, which repo
**Tool/Workflow**: The specific tool or workflow step involved
**Issue**: What went wrong or could be improved
**Reproduction**: Steps to reproduce (if applicable)
**Affected files**: File paths and line numbers
**Suggested investigation**: What an agent should look into
**Reported by**: <subagent profile name>
```
3. **Report back**: Include the issue URL in your report to the parent agent.
### When NOT to Create Feedback Issues
- Transient failures (network blips, rate limits, Docker pull flakiness)
- Issues you can fix yourself — fix them instead
- CI run failures — those are handled by `notify_failure` automatically
- Missing labels — `configure_repo` creates standard labels on next master push
+38
View File
@@ -0,0 +1,38 @@
# devx-workflow
Quick reference for devx tools when working on this repo.
## PR Workflow (use these, not raw git/tea/MCP)
| Task | Command |
|------|---------|
| Create Vikunja task | `make create-task -- --title "..." --description "..."` |
| Create PR | `make create-pr` |
| Push + create PR | `make push-with-pr` |
| Check CI status | `make devx-pr-status` or `make devx-pr-status PR=42 WAIT=1` |
| Fetch CI failure logs | `make devx-pr-logs` or `make devx-pr-logs PR=42 JOB=quality TAIL=50` |
| Add ready-to-merge label | `make devx-pr-label` or `make devx-pr-label PR=42` |
| Post PR review | `make devx-pr-review PR=42 EVENT=APPROVE BODY="..." CHECKLIST=1,2,3,4,5,6,7,8,9,10,11,12,13` |
| Rebase current branch | `make rebase` |
| Rebase PR via API | `make pr-rebase` or `make pr-rebase PR=42` |
## Auto-merge Behavior
When the `ready-to-merge` label is added and all CI checks pass:
1. Auto-merge validates PR title format (`GRM-N: <vikunja task title>`)
2. If branch is behind master, auto-merge **rebases via Gitea API** automatically
3. The rebase triggers a new CI run; the next auto-merge attempt merges
4. No manual rebase needed unless the API rebase fails
## Pre-merge Check
CI runs a `pre-merge-check` job early (after quality + detect-changes)
that validates branch format, PR title, and Vikunja task match.
This fails fast before expensive molecule tests run.
## Key Rules
- Never manually merge via API — always use auto-merge with `ready-to-merge` label
- Branch naming: `GRM-N-short-description` (N = Vikunja task ID)
- Commit format: conventional commits (`feat:`, `fix:`, `docs:`, etc.)
- PR title: `GRM-N: <vikunja task title>` (auto-derived by `make create-pr`)
+53 -19
View File
@@ -23,12 +23,12 @@ jobs:
run: make setup-image EXTRAS=lint
- name: Lint all
run: |
. .venv/bin/activate
. .venv/bin/activate 2>/dev/null || true
export PATH="$HOME/.local/bin:$PATH"
make lint-all
- name: Unit tests with 100% coverage
run: |
. .venv/bin/activate
. .venv/bin/activate 2>/dev/null || true
make pytest-cov
- name: Documentation lint check
env:
@@ -36,31 +36,31 @@ jobs:
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
CI_GITEA_USERNAME: ${{ vars.CI_GITEA_USERNAME }}
run: |
. .venv/bin/activate
. .venv/bin/activate 2>/dev/null || true
pip install --upgrade devx \
--index-url "https://${CI_GITEA_USERNAME}:${CI_GITEA_TOKEN}@git.oblachno.oblachno.fyi/api/packages/oblachno-oss/pypi/simple/" \
--no-deps
python3 -m devx.ci.lint_docs --root .
- name: Translation completeness check
run: |
. .venv/bin/activate
. .venv/bin/activate 2>/dev/null || true
python3 -m devx.ci.check_translations --translations src/gitea_runner_manager/translations.json
- name: Check unit test speed
env:
PYTHONPATH: src
run: |
. .venv/bin/activate
. .venv/bin/activate 2>/dev/null || true
python3 -m devx.tools.check_test_speed --max-seconds 4 --max-single-seconds 0.5
- name: Dependency security scan
run: |
. .venv/bin/activate
. .venv/bin/activate 2>/dev/null || true
# Install pip in venv if missing (needed by pip-audit)
.venv/bin/python -m ensurepip 2>/dev/null || true
PIPAPI_PYTHON_LOCATION=$PWD/.venv/bin/python \
pip-audit --desc --skip-editable 2>&1 || true
- name: Workflow dry-run validation
run: |
. .venv/bin/activate
. .venv/bin/activate 2>/dev/null || true
export PATH="$HOME/.local/bin:$PATH"
# Best-effort: only runs if act_runner is installed
if command -v act_runner >/dev/null 2>&1; then
@@ -90,7 +90,7 @@ jobs:
DEVX_VERSION_FILE: src/gitea_runner_manager/__init__.py
DEVX_TASK_PREFIX: GRM
run: |
. .venv/bin/activate
. .venv/bin/activate 2>/dev/null || true
export PATH="$HOME/.local/bin:$PATH"
python3 -m devx.ci.release --dry-run
@@ -116,12 +116,43 @@ jobs:
PYTHONPATH: src
DEVX_TASK_PREFIX: GRM
run: |
. .venv/bin/activate
. .venv/bin/activate 2>/dev/null || true
python3 -m devx.ci.classify_changes \
--base "origin/master" \
--head "${{ github.event.pull_request.head.sha || github.sha }}" \
--github-output
pre-merge-check:
needs: [quality, detect-changes]
if: github.event_name == 'pull_request'
runs-on: docker
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-base:latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up environment
run: make setup-image EXTRAS=ci
- name: Validate auto-merge preconditions
env:
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
VIKUNJA_TOKEN: ${{ secrets.VIKUNJA_TOKEN }}
DEVX_TASK_PREFIX: GRM
DEVX_VIKUNJA_PROJECT_ID: 6
HEAD_REF: ${{ github.head_ref }}
PR_TITLE: ${{ github.event.pull_request.title }}
REPOSITORY: ${{ github.repository }}
PR_NUMBER: ${{ github.event.number }}
PYTHONPATH: ${{ env.PYTHONPATH }}
run: |
. .venv/bin/activate 2>/dev/null || true
python3 -m devx.ci.check_auto_merge_ready \
--branch "$HEAD_REF" \
--pr-title "$PR_TITLE" \
--repo "$REPOSITORY" \
--pr-number "$PR_NUMBER"
discover-runners:
needs: [detect-changes]
if: needs.detect-changes.outputs.ansible-changed == 'true'
@@ -145,7 +176,7 @@ jobs:
MOLECULE_RUNNERS: ${{ vars.MOLECULE_RUNNERS }}
PYTHONPATH: src
run: |
. .venv/bin/activate
. .venv/bin/activate 2>/dev/null || true
python3 -m devx.molecule.discover_runners \
--owner "${{ github.repository_owner }}" \
--repo "${{ github.event.repository.name }}" \
@@ -158,8 +189,10 @@ jobs:
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-full:latest
timeout-minutes: 10
strategy:
fail-fast: true
max-parallel: 3
matrix:
runner-index: [1, 2, 3]
runner-index: [1, 2, 3, 4, 5, 6]
steps:
- uses: actions/checkout@v4
- name: Set up environment
@@ -169,7 +202,7 @@ jobs:
run: make setup-image EXTRAS=ci,molecule
- name: Install Ansible collections
run: |
. .venv/bin/activate
. .venv/bin/activate 2>/dev/null || true
python3 -m devx.tools.setup --skip-install --no-pre-commit --no-tea-login
- name: Discover assigned test pairs
env:
@@ -177,7 +210,7 @@ jobs:
MAX_RUNNERS: ${{ needs.discover-runners.outputs.runner-count }}
PYTHONPATH: src
run: |
. .venv/bin/activate
. .venv/bin/activate 2>/dev/null || true
python3 -m devx.molecule.distribute_molecule \
--runner-index "$RUNNER_INDEX" \
--max-runners "$MAX_RUNNERS" \
@@ -185,7 +218,7 @@ jobs:
- name: Run molecule tests
if: env.SKIP != 'true'
run: |
. .venv/bin/activate
. .venv/bin/activate 2>/dev/null || true
if [ -z "$TEST_PAIRS" ]; then exit 0; fi
if ! python3 -c "import docker; docker.from_env().ping()" 2>/dev/null; then
echo "Docker not available in CI container — skipping molecule tests"
@@ -227,7 +260,7 @@ jobs:
PYTHONPATH: src
run: |
set -euo pipefail
. .venv/bin/activate
. .venv/bin/activate 2>/dev/null || true
python3 -m devx.ci.pr_review \
"${{ github.event.number }}" \
"${{ github.repository }}"
@@ -237,11 +270,12 @@ jobs:
# from the branch name, validates the PR title, and squash-merges.
# Uses always() so it evaluates even when molecule-tests is skipped
# (Gitea Actions skips dependent jobs of skipped jobs by default).
needs: [quality, detect-changes, pr-review, molecule-tests, release-dry-run]
needs: [quality, detect-changes, pre-merge-check, pr-review, molecule-tests, release-dry-run]
if: >-
always() &&
github.event_name == 'pull_request' &&
needs.quality.result == 'success' &&
needs.pre-merge-check.result == 'success' &&
needs.pr-review.result == 'success' &&
(needs.molecule-tests.result == 'success' || needs.molecule-tests.result == 'skipped') &&
(needs.release-dry-run.result == 'success' || needs.release-dry-run.result == 'skipped')
@@ -268,14 +302,14 @@ jobs:
REPOSITORY: ${{ github.repository }}
PYTHONPATH: src
run: |
. .venv/bin/activate
. .venv/bin/activate 2>/dev/null || true
python3 -m devx.ci.pr_review \
"$PR_NUMBER" \
"$REPOSITORY" \
--event APPROVE \
--checklist-confirmed \
--checklist-categories 1,2,3,4,5,6,7,8,9,10,11,12,13 \
--body "Auto-approved: all CI checks passed (quality, molecule, pr-review)."
--body "Auto-approved: all CI checks passed (quality, molecule, pr-review, pre-merge-check)."
- name: Squash merge with task ID
env:
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
@@ -288,7 +322,7 @@ jobs:
REPOSITORY: ${{ github.repository }}
PR_NUMBER: ${{ github.event.number }}
run: |
. .venv/bin/activate
. .venv/bin/activate 2>/dev/null || true
python3 -m devx.ci.auto_merge \
"$HEAD_REF" \
"$PR_TITLE" \
+8 -8
View File
@@ -56,7 +56,7 @@ jobs:
env:
PYTHONPATH: src
run: |
. .venv/bin/activate
. .venv/bin/activate 2>/dev/null || true
python3 -m devx.ci.detect_release_commit
validate-commit-msg:
@@ -79,7 +79,7 @@ jobs:
PYTHONPATH: src
DEVX_TASK_PREFIX: GRM
run: |
. .venv/bin/activate
. .venv/bin/activate 2>/dev/null || true
git log -1 --format=%B > commit-msg.txt
python3 -m devx.ci.validate_commit_msg commit-msg.txt --branch master
rm -f commit-msg.txt
@@ -114,7 +114,7 @@ jobs:
DEVX_TASK_PREFIX: GRM
DEVX_VIKUNJA_PROJECT_ID: 6
run: |
. .venv/bin/activate
. .venv/bin/activate 2>/dev/null || true
export PATH="$HOME/.local/bin:$PATH"
python3 -m devx.ci.release
- name: Notify on failure
@@ -152,7 +152,7 @@ jobs:
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
PYTHONPATH: src
run: |
. .venv/bin/activate
. .venv/bin/activate 2>/dev/null || true
export PATH="$HOME/.local/bin:$PATH"
python3 -m devx.ci.publish \
"${{ needs.release.outputs.tag }}" \
@@ -191,7 +191,7 @@ jobs:
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
PYTHONPATH: src
run: |
. .venv/bin/activate
. .venv/bin/activate 2>/dev/null || true
python3 -m devx.ci.sync_wiki --repo "${{ github.repository }}" --strict
- name: Notify on failure
if: failure()
@@ -231,7 +231,7 @@ jobs:
env:
PRE_COMMIT_ALLOW_NO_CONFIG: "1"
run: |
. .venv/bin/activate
. .venv/bin/activate 2>/dev/null || true
python3 -m devx.ci.push_badges
- name: Notify on failure
if: failure()
@@ -268,7 +268,7 @@ jobs:
DEVX_TASK_PREFIX: GRM
DEVX_VIKUNJA_PROJECT_ID: 6
run: |
. .venv/bin/activate
. .venv/bin/activate 2>/dev/null || true
python3 -m devx.ci.post_merge --git-sha "${{ github.sha }}"
- name: Notify on failure
if: failure()
@@ -304,7 +304,7 @@ jobs:
DEVX_REPO_OWNER: oblachno-oss
DEVX_STATUS_CHECKS: "CI / quality (pull_request),CI / molecule-tests (1) (pull_request),CI / molecule-tests (2) (pull_request),CI / molecule-tests (3) (pull_request)"
run: |
. .venv/bin/activate
. .venv/bin/activate 2>/dev/null || true
python3 -m devx.tools.configure_repo
- name: Notify on failure
if: failure()
+101 -6
View File
@@ -163,6 +163,12 @@ Then add the `ready-to-merge` label. The auto-merge workflow will:
5. The post-merge workflow marks the Vikunja task as done
6. The release workflow automatically versions, tags, and publishes (see below)
**If the branch is behind master** (another PR merged first), auto-merge
automatically rebases the PR's head branch via the Gitea API. This triggers
a new CI run. The next auto-merge attempt will merge successfully.
No manual rebase needed. To rebase manually: `make rebase` (local) or
`make pr-rebase` (server-side via API).
> **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, producing incorrectly named commits.
@@ -171,6 +177,10 @@ Then add the `ready-to-merge` label. The auto-merge workflow will:
### CI Path Filtering
The CI workflow includes a `pre-merge-check` job (runs after quality +
detect-changes) that validates branch format, PR title, and Vikunja task
match. This fails fast before expensive molecule tests run.
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
@@ -242,11 +252,11 @@ Not all changes require the full CI pipeline or a new release. The project
classifies changes into two categories using `devx.ci.classify_changes`:
**Classification strategy (safe-by-default):** Any file NOT in the explicit
workflow-only allowlist is treated as user-facing. This prevents new file
infrastructure 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`.
**Workflow-only paths** (infrastructure no release needed):
**Infrastructure paths** (no release needed):
- `.gitea/**` — Gitea Actions workflows
- `scripts/**` — Dev tools and CI/CD automation (not part of installed package)
- `docs/**` — Documentation
@@ -267,7 +277,7 @@ via `[tool.devx.classify]` in `pyproject.toml`.
**devx module structure** (installed from git, not in this repo):
- `devx.ci.*` — CI/CD automation (run by workflows): release, publish, auto_merge, classify_changes, detect_release_commit, push_badges, doc_coverage, sync_wiki, distribute_molecule, molecule_ci_guard, discover_runners, notify_failure, post_merge, pr_review, validate_commit_msg
- `devx.tools.*` — Dev tools (run locally): check_test_speed, configure_repo, install_checkmake, install_tools, setup, generate_badges
- `devx.tools.*` — Dev tools (run locally): check_test_speed, configure_repo, install_checkmake, install_tools, setup, generate_badges, create_task, create_pr, pr_status, pr_logs, pr_label, rebase, pr_rebase
- `devx.molecule.*` — Molecule helpers: molecule_all, platforms, discover_runners, distribute_molecule, molecule_ci_guard
- `devx.gitea_cli` — Tea CLI wrapper
- `devx.i18n` — i18n translation system
@@ -283,7 +293,7 @@ via `[tool.devx.classify]` in `pyproject.toml`.
**AI agents must follow these rules:**
- 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
- Do NOT bump the version or create tags for infrastructure-only changes
- The `classify_changes` module enforces this automatically — no manual intervention needed
## Source Code Separation and devx Integration
@@ -397,7 +407,7 @@ The devx package is configured via `DEVX_*` environment variables:
- `DEVX_VIKUNJA_PROJECT_ID=6` — Vikunja project ID for task tracking
- `DEVX_VERSION_FILE=src/gitea_runner_manager/__init__.py` — Path to the version source file
Change classification is config-driven via `[tool.devx.classify]` in `pyproject.toml`, which defines the workflow-only and user-facing path patterns.
Change classification is config-driven via `[tool.devx.classify]` in `pyproject.toml`, which defines the infrastructure and user-facing path patterns.
## Key Conventions
@@ -422,10 +432,12 @@ main.yml → systemd_check → user_setup → rootless_docker → install_runner
## Molecule Scenarios
6 scenarios: `default`, `multi-instance`, `lifecycle`, `template-content`, `deregister`, `update`
7 scenarios: `default`, `multi-instance`, `lifecycle`, `template-content`, `deregister`, `update`, `remove`
4 platforms: `ubuntu-2204`, `ubuntu-2404`, `debian-12`, `archlinux`
Platform list is defined in `devx.molecule.platforms` (single source of truth)
Note: `make molecule` and `make molecule-all` run 6 scenarios (excluding `remove`, which destroys the test container). CI discovers all 7 scenarios via `devx.molecule.distribute_molecule`.
## Known Issues
- `ansible-lint` may warn about `command-instead-of-module` for `systemctl --user` calls — this is expected (systemd module doesn't support user services) and skipped in `.ansible-lint`
@@ -475,3 +487,86 @@ docs/
2. If adding a new page, add it to `docs/mapping.json`
3. Commit and create a PR (standard PR workflow)
4. On merge, wiki is automatically synced
## Subagent Delegation Policy
Custom subagent profiles are defined in `.devin/agents/` (project-specific)
and `~/.config/devin/agents/` (global, shared across repos). The agent MUST
automatically delegate to the appropriate subagent based on the task —
the user should not need to specify which profile to use.
### Available Profiles
**Global** (shared with infra and devx):
| Profile | Location | Purpose |
|---------|----------|---------|
| `pr-reviewer` | `~/.config/devin/agents/` | 13-category PR checklist + quality gates |
| `release-check` | `~/.config/devin/agents/` | Pre-merge readiness validation |
**grm-specific** (in `.devin/agents/`):
| Profile | Purpose |
|---------|---------|
| `ci-investigator` | Investigate CI failures (quality, molecule, release, publish, wiki sync) |
| `molecule-runner` | Run 7 molecule scenarios across 4 platforms, report pass/fail |
| `dep-upgrader` | Python + Ansible dependency upgrades with molecule verification |
| `doc-syncer` | Doc coverage, doc linting, wiki sync for grm docs |
| `workflow-validator` | actionlint + act_runner dry-run for grm workflows |
### When to Delegate Automatically
| Trigger | Profile | Mode |
|---------|---------|------|
| CI run failure (quality, molecule-tests, release, publish, sync-wiki) | `ci-investigator` | Background |
| PR ready for review | `pr-reviewer` | Foreground |
| Molecule tests need to run | `molecule-runner` | Background |
| Dependency upgrade requested | `dep-upgrader` | Background |
| Doc coverage failure or wiki sync issue | `doc-syncer` | Background |
| Workflow YAML modified or validation needed | `workflow-validator` | Background |
| Branch ready for merge | `release-check` | Foreground |
### Delegation Rules
1. **Auto-select the profile.** Do not ask the user which profile to use.
2. **Background by default, foreground when blocking.**
3. **Provide full context in the prompt** — subagents don't inherit conversation history.
4. **One subagent per concern.** Chain: investigate → fix in main session → review.
5. **Don't delegate trivial work** (<30s, <50 lines of context).
6. **Compact after subagent returns.**
7. **Never skip delegation to save time** — it keeps main context small.
## Feedback Issue Handling
Subagents create Gitea issues in the current repo when they encounter
tool, workflow, or process issues that warrant follow-up. These issues
use the `feedback` label plus a category label (`tooling`,
`ci-improvement`, `doc-improvement`, `workflow-improvement`).
Standard labels are created automatically by `configure_repo` (runs in
post-merge on every master push). If a label does not exist yet, the
subagent's issue creation will still succeed — labels can be added
afterwards.
### When a Subagent Reports a Feedback Issue URL
1. **Acknowledge it** in your response to the user — mention the issue URL
2. **Do NOT close or modify** the issue — it is for follow-up work
3. **Do NOT create a PR** to address it unless the user explicitly asks
4. If the user asks to address feedback, spawn a subagent to investigate
the issue and implement a fix
### Creating Feedback Issues Manually
As the parent agent, you can also create feedback issues directly using
the Gitea MCP (`issue_write` with `create_issue` method). Follow the
same format as subagents:
- Title: `[feedback] <category>: <short description>`
- Labels: `feedback` + category label
- Body: include context, tool/workflow, issue, reproduction, affected
files, suggested investigation, and "Reported by: parent agent"
Always deduplicate first via `list_issues` with `labels: "feedback"`.
+25
View File
@@ -2,6 +2,31 @@
All notable changes to this project will be documented in this file.
## [0.14.0] - 2026-07-01
### Features
- Bump devx to v0.30.0
## [0.13.0] - 2026-07-01
### Features
- Bump devx to v0.29.1, upgrade molecule, ubuntu 26.04
## [0.12.5] - 2026-06-30
### Bug Fixes
- Right-size molecule-tests matrix to [1-6]
- Cast disk threshold to string in template-content verify assertion
## [0.12.4] - 2026-06-29
### Bug Fixes
- Use hardcoded matrix array for Gitea 1.26 compatibility
## [0.12.3] - 2026-06-29
### Bug Fixes
+7 -6
View File
@@ -8,12 +8,12 @@ Each runner runs in an isolated **rootless Docker** environment under a dedicate
[![CI](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions/workflows/ci.yml/badge.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
[![License: GPL-3.0](https://img.shields.io/badge/license-GPL--3.0-blue)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/src/branch/master/LICENSE)
[![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/695921d6cc831b859d7878ff93a9bd03e58c056e/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/695921d6cc831b859d7878ff93a9bd03e58c056e/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/695921d6cc831b859d7878ff93a9bd03e58c056e/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/695921d6cc831b859d7878ff93a9bd03e58c056e/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/695921d6cc831b859d7878ff93a9bd03e58c056e/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/695921d6cc831b859d7878ff93a9bd03e58c056e/python.svg)](https://www.python.org/downloads/)
[![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/647c885cba1418fa177a9daa0ac25d7c27300a03/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/647c885cba1418fa177a9daa0ac25d7c27300a03/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/647c885cba1418fa177a9daa0ac25d7c27300a03/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/647c885cba1418fa177a9daa0ac25d7c27300a03/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/647c885cba1418fa177a9daa0ac25d7c27300a03/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/647c885cba1418fa177a9daa0ac25d7c27300a03/python.svg)](https://www.python.org/downloads/)
## Why GRM?
@@ -151,6 +151,7 @@ GRM provides a single `grm` command with subcommands for the full runner lifecyc
| `grm remove <name> --force` | Remove only the local registry entry (skip remote cleanup) |
| `grm list` | List all registered runners with live status |
| `grm list --no-status` | List registered runners without SSH status checks |
| `grm health [name]` | Run health check (Docker, runner service, disk) on one or all runners |
| `grm trigger-workflow <workflow_id>` | Trigger a Gitea Actions workflow via the API |
| `grm trigger-workflow --list` | List available workflows in the repository |
| `grm --version` | Show the installed version |
+10
View File
@@ -18,6 +18,16 @@
when: systemd_available.stat.exists
changed_when: true
- name: Stop and disable healthcheck timer
ansible.builtin.command: systemctl --user stop --disable runner-healthcheck.timer
become: true
become_user: "{{ gitea_runner_service_user | default('grm-' ~ runner_name) }}"
environment:
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid | default('') }}"
when: systemd_available.stat.exists
changed_when: true
failed_when: false
- name: Include deregistration
ansible.builtin.include_role:
name: gitea-runner
+28
View File
@@ -79,6 +79,16 @@
tasks_from: deregister.yml
when: not skip_runner_registration | default(false)
- name: Stop and disable healthcheck timer
ansible.builtin.command: systemctl --user stop --disable runner-healthcheck.timer
become: true
become_user: "{{ gitea_runner_service_user | default('grm-' ~ runner_name) }}"
environment:
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid | default('') }}"
when: systemd_available.stat.exists
changed_when: true
failed_when: false
- name: Remove docker-prune user service file
ansible.builtin.file:
path: "{{ gitea_runner_home | default('/home/grm-' ~ runner_name) }}/.config/systemd/user/docker-prune.service"
@@ -91,6 +101,24 @@
state: absent
failed_when: false
- name: Remove healthcheck user service file
ansible.builtin.file:
path: "{{ gitea_runner_home | default('/home/grm-' ~ runner_name) }}/.config/systemd/user/runner-healthcheck.service"
state: absent
failed_when: false
- name: Remove healthcheck user timer file
ansible.builtin.file:
path: "{{ gitea_runner_home | default('/home/grm-' ~ runner_name) }}/.config/systemd/user/runner-healthcheck.timer"
state: absent
failed_when: false
- name: Remove healthcheck script
ansible.builtin.file:
path: "{{ gitea_runner_config_dir | default('/etc/gitea-runner/' ~ runner_name) }}/healthcheck.sh"
state: absent
failed_when: false
- name: Remove systemd user unit file
ansible.builtin.file:
path: "{{ gitea_runner_home | default('/home/grm-' ~ runner_name) }}/.config/systemd/user/gitea-runner.service"
+7 -1
View File
@@ -1,6 +1,6 @@
---
gitea_runner_version: "1.0.8"
runner_labels: "docker,ubuntu-latest:docker://runner-images:ubuntu-22.04"
runner_labels: "docker,ubuntu-latest:docker://runner-images:ubuntu-26.04"
skip_runner_registration: false
# Per-runner user (rootless isolation)
@@ -24,6 +24,12 @@ gitea_runner_prune_label: "gitea-runner=true"
# Service configuration
gitea_runner_service_restart_sec: "5"
# Health check configuration
gitea_runner_healthcheck_interval: "5min"
gitea_runner_healthcheck_boot_delay: "2min"
gitea_runner_healthcheck_disk_threshold: 85
gitea_runner_healthcheck_script_path: "{{ gitea_runner_config_dir }}/healthcheck.sh"
# Admin token for runner deregistration via Gitea API.
# If not set, falls back to registration_token (which likely lacks admin scope).
# Set this to a token with admin scope to enable automatic runner cleanup on removal.
@@ -4,7 +4,7 @@ driver:
platforms:
- name: ${MOLECULE_PLATFORM_NAME:-ubuntu-2204}
image: ${MOLECULE_PLATFORM_IMAGE:-ubuntu:22.04}
image: ${MOLECULE_PLATFORM_IMAGE:-ubuntu:26.04}
command: ${MOLECULE_PLATFORM_COMMAND:-sleep infinity}
volumes:
- /sys/fs/cgroup:/sys/fs/cgroup:rw
@@ -75,3 +75,42 @@
that:
- timer_stat.stat.exists
fail_msg: "Docker prune timer is missing"
- name: Check healthcheck script exists
ansible.builtin.stat:
path: "{{ gitea_runner_healthcheck_script_path }}"
register: healthcheck_script_stat
- name: Assert healthcheck script exists
ansible.builtin.assert:
that:
- healthcheck_script_stat.stat.exists
fail_msg: "Healthcheck script is missing"
- name: Assert healthcheck script is executable
ansible.builtin.assert:
that:
- healthcheck_script_stat.stat.mode == "0755"
fail_msg: "Healthcheck script is not executable"
- name: Check healthcheck service exists
ansible.builtin.stat:
path: "{{ gitea_runner_home }}/.config/systemd/user/runner-healthcheck.service"
register: healthcheck_service_stat
- name: Assert healthcheck service exists
ansible.builtin.assert:
that:
- healthcheck_service_stat.stat.exists
fail_msg: "Healthcheck systemd service is missing"
- name: Check healthcheck timer exists
ansible.builtin.stat:
path: "{{ gitea_runner_home }}/.config/systemd/user/runner-healthcheck.timer"
register: healthcheck_timer_stat
- name: Assert healthcheck timer exists
ansible.builtin.assert:
that:
- healthcheck_timer_stat.stat.exists
fail_msg: "Healthcheck systemd timer is missing"
@@ -4,7 +4,7 @@ driver:
platforms:
- name: ${MOLECULE_PLATFORM_NAME:-ubuntu-2204}
image: ${MOLECULE_PLATFORM_IMAGE:-ubuntu:22.04}
image: ${MOLECULE_PLATFORM_IMAGE:-ubuntu:26.04}
command: ${MOLECULE_PLATFORM_COMMAND:-sleep infinity}
volumes:
- /sys/fs/cgroup:/sys/fs/cgroup:rw
@@ -4,7 +4,7 @@ driver:
platforms:
- name: ${MOLECULE_PLATFORM_NAME:-ubuntu-2204}
image: ${MOLECULE_PLATFORM_IMAGE:-ubuntu:22.04}
image: ${MOLECULE_PLATFORM_IMAGE:-ubuntu:26.04}
command: ${MOLECULE_PLATFORM_COMMAND:-sleep infinity}
volumes:
- /sys/fs/cgroup:/sys/fs/cgroup:rw
@@ -4,7 +4,7 @@ driver:
platforms:
- name: ${MOLECULE_PLATFORM_NAME:-ubuntu-2204}
image: ${MOLECULE_PLATFORM_IMAGE:-ubuntu:22.04}
image: ${MOLECULE_PLATFORM_IMAGE:-ubuntu:26.04}
command: ${MOLECULE_PLATFORM_COMMAND:-sleep infinity}
volumes:
- /sys/fs/cgroup:/sys/fs/cgroup:rw
@@ -4,7 +4,7 @@ driver:
platforms:
- name: ${MOLECULE_PLATFORM_NAME:-ubuntu-2204}
image: ${MOLECULE_PLATFORM_IMAGE:-ubuntu:22.04}
image: ${MOLECULE_PLATFORM_IMAGE:-ubuntu:26.04}
command: ${MOLECULE_PLATFORM_COMMAND:-sleep infinity}
volumes:
- /sys/fs/cgroup:/sys/fs/cgroup:rw
@@ -89,6 +89,39 @@
- not prune_timer_stat.stat.exists
fail_msg: "docker-prune timer unit still exists after removal"
- name: Check healthcheck service unit is absent
ansible.builtin.stat:
path: "{{ gitea_runner_home }}/.config/systemd/user/runner-healthcheck.service"
register: healthcheck_service_stat
- name: Assert healthcheck service unit is absent
ansible.builtin.assert:
that:
- not healthcheck_service_stat.stat.exists
fail_msg: "runner-healthcheck service unit still exists after removal"
- name: Check healthcheck timer unit is absent
ansible.builtin.stat:
path: "{{ gitea_runner_home }}/.config/systemd/user/runner-healthcheck.timer"
register: healthcheck_timer_stat
- name: Assert healthcheck timer unit is absent
ansible.builtin.assert:
that:
- not healthcheck_timer_stat.stat.exists
fail_msg: "runner-healthcheck timer unit still exists after removal"
- name: Check healthcheck script is absent
ansible.builtin.stat:
path: "{{ gitea_runner_config_dir }}/healthcheck.sh"
register: healthcheck_script_stat
- name: Assert healthcheck script is absent
ansible.builtin.assert:
that:
- not healthcheck_script_stat.stat.exists
fail_msg: "healthcheck script still exists after removal"
- name: Check subuid entry is absent
ansible.builtin.command: "grep -c '^{{ gitea_runner_service_user }}:' /etc/subuid"
register: subuid_check
@@ -4,7 +4,7 @@ driver:
platforms:
- name: ${MOLECULE_PLATFORM_NAME:-ubuntu-2204}
image: ${MOLECULE_PLATFORM_IMAGE:-ubuntu:22.04}
image: ${MOLECULE_PLATFORM_IMAGE:-ubuntu:26.04}
command: ${MOLECULE_PLATFORM_COMMAND:-sleep infinity}
volumes:
- /sys/fs/cgroup:/sys/fs/cgroup:rw
@@ -62,3 +62,45 @@
- "'OnCalendar={{ gitea_runner_prune_schedule }}' in prune_timer.content | b64decode"
- "'Persistent=true' in prune_timer.content | b64decode"
fail_msg: "Prune timer template is missing expected directives"
- name: Read rendered healthcheck service template
ansible.builtin.slurp:
src: "{{ gitea_runner_home }}/.config/systemd/user/runner-healthcheck.service"
register: healthcheck_service
- name: Assert healthcheck service contains expected directives
ansible.builtin.assert:
that:
- "'Type=oneshot' in healthcheck_service.content | b64decode"
- "'ExecStart={{ gitea_runner_healthcheck_script_path }}' in healthcheck_service.content | b64decode"
- "'DOCKER_HOST=unix:///run/user/' in healthcheck_service.content | b64decode"
- "'XDG_RUNTIME_DIR=/run/user/' in healthcheck_service.content | b64decode"
fail_msg: "Healthcheck service template is missing expected directives"
- name: Read rendered healthcheck timer template
ansible.builtin.slurp:
src: "{{ gitea_runner_home }}/.config/systemd/user/runner-healthcheck.timer"
register: healthcheck_timer
- name: Assert healthcheck timer contains expected directives
ansible.builtin.assert:
that:
- "'OnBootSec={{ gitea_runner_healthcheck_boot_delay }}' in healthcheck_timer.content | b64decode"
- "'OnUnitActiveSec={{ gitea_runner_healthcheck_interval }}' in healthcheck_timer.content | b64decode"
- "'Persistent=true' in healthcheck_timer.content | b64decode"
fail_msg: "Healthcheck timer template is missing expected directives"
- name: Read rendered healthcheck script
ansible.builtin.slurp:
src: "{{ gitea_runner_healthcheck_script_path }}"
register: healthcheck_script
- name: Assert healthcheck script contains expected content
ansible.builtin.assert:
that:
- "'docker info' in healthcheck_script.content | b64decode"
- "'systemctl --user restart docker.service' in healthcheck_script.content | b64decode"
- "'systemctl --user restart gitea-runner.service' in healthcheck_script.content | b64decode"
- "'docker system prune' in healthcheck_script.content | b64decode"
- "gitea_runner_healthcheck_disk_threshold | string in healthcheck_script.content | b64decode"
fail_msg: "Healthcheck script template is missing expected content"
@@ -4,7 +4,7 @@ driver:
platforms:
- name: ${MOLECULE_PLATFORM_NAME:-ubuntu-2204}
image: ${MOLECULE_PLATFORM_IMAGE:-ubuntu:22.04}
image: ${MOLECULE_PLATFORM_IMAGE:-ubuntu:26.04}
command: ${MOLECULE_PLATFORM_COMMAND:-sleep infinity}
volumes:
- /sys/fs/cgroup:/sys/fs/cgroup:rw
@@ -0,0 +1,46 @@
---
- name: Create healthcheck script
ansible.builtin.template:
src: runner-healthcheck.sh.j2
dest: "{{ gitea_runner_healthcheck_script_path }}"
owner: "{{ gitea_runner_service_user }}"
group: "{{ gitea_runner_service_user }}"
mode: "0755"
- name: Create healthcheck user service file
ansible.builtin.template:
src: runner-healthcheck.service.j2
dest: "{{ gitea_runner_home }}/.config/systemd/user/runner-healthcheck.service"
owner: "{{ gitea_runner_service_user }}"
group: "{{ gitea_runner_service_user }}"
mode: "0644"
- name: Create healthcheck user timer file
ansible.builtin.template:
src: runner-healthcheck.timer.j2
dest: "{{ gitea_runner_home }}/.config/systemd/user/runner-healthcheck.timer"
owner: "{{ gitea_runner_service_user }}"
group: "{{ gitea_runner_service_user }}"
mode: "0644"
- name: Reload systemd user daemon for healthcheck timer
ansible.builtin.command: systemctl --user daemon-reload
become: true
become_user: "{{ gitea_runner_service_user }}"
environment:
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}"
changed_when: true
when:
- systemd_available.stat.exists
- docker_rootless_setup
- name: Enable and start healthcheck user timer
ansible.builtin.command: systemctl --user enable --now runner-healthcheck.timer
become: true
become_user: "{{ gitea_runner_service_user }}"
environment:
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}"
changed_when: true
when:
- systemd_available.stat.exists
- docker_rootless_setup
@@ -14,6 +14,9 @@
- name: Include prune setup
ansible.builtin.include_tasks: prune.yml
- name: Include healthcheck setup
ansible.builtin.include_tasks: healthcheck.yml
- name: Include integration test
ansible.builtin.include_tasks: integration_test.yml
when: not skip_runner_registration
@@ -0,0 +1,9 @@
[Unit]
Description=Gitea Runner health check (Docker + service + disk)
After=docker.service gitea-runner.service
[Service]
Type=oneshot
Environment=DOCKER_HOST=unix:///run/user/{{ gitea_runner_uid }}/docker.sock
Environment=XDG_RUNTIME_DIR=/run/user/{{ gitea_runner_uid }}
ExecStart={{ gitea_runner_healthcheck_script_path }}
@@ -0,0 +1,49 @@
#!/bin/bash
# Health check for gitea-runner: verifies Docker daemon and runner service.
# Exits 0 if healthy, 1 if Docker is down (triggers restart), 2 if runner is down.
set -euo pipefail
DOCKER_HOST="unix:///run/user/{{ gitea_runner_uid }}/docker.sock"
XDG_RUNTIME_DIR="/run/user/{{ gitea_runner_uid }}"
export DOCKER_HOST XDG_RUNTIME_DIR
# 1. Check Docker daemon responsiveness
if ! docker info >/dev/null 2>&1; then
echo "ERROR: Docker daemon not responding at ${DOCKER_HOST}"
systemctl --user restart docker.service
sleep 3
if ! docker info >/dev/null 2>&1; then
echo "CRITICAL: Docker daemon still down after restart"
exit 1
fi
echo "RECOVERED: Docker daemon restarted successfully"
fi
# 2. Check gitea-runner service is active
runner_state=$(systemctl --user is-active gitea-runner.service 2>/dev/null || true)
if [[ "$runner_state" != "active" ]]; then
echo "ERROR: gitea-runner service is ${runner_state}, restarting"
systemctl --user restart gitea-runner.service
sleep 2
runner_state=$(systemctl --user is-active gitea-runner.service 2>/dev/null || true)
if [[ "$runner_state" != "active" ]]; then
echo "CRITICAL: gitea-runner service still down after restart"
exit 2
fi
echo "RECOVERED: gitea-runner service restarted successfully"
fi
# 3. Check disk space — prune aggressively if below threshold
disk_pct=$(df -P / | awk 'NR==2 {gsub(/%/, "", $5); print $5}')
if [[ "$disk_pct" -ge {{ gitea_runner_healthcheck_disk_threshold }} ]]; then
echo "WARN: Disk usage at ${disk_pct}%, pruning all runner resources"
docker system prune -af --filter "label={{ gitea_runner_prune_label }}" --filter "until=1h" || true
docker volume prune -af --filter "label={{ gitea_runner_prune_label }}" || true
# Also prune dangling images (no label)
docker image prune -af || true
disk_pct=$(df -P / | awk 'NR==2 {gsub(/%/, "", $5); print $5}')
echo "INFO: Disk usage after prune: ${disk_pct}%"
fi
echo "OK: runner healthy, disk at ${disk_pct}%"
exit 0
@@ -0,0 +1,10 @@
[Unit]
Description=Periodic Gitea Runner health check
[Timer]
OnBootSec={{ gitea_runner_healthcheck_boot_delay }}
OnUnitActiveSec={{ gitea_runner_healthcheck_interval }}
Persistent=true
[Install]
WantedBy=timers.target
+6 -6
View File
@@ -8,12 +8,12 @@ Each runner runs in an isolated **rootless Docker** environment under a dedicate
[![CI](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions/workflows/ci.yml/badge.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
[![License: GPL-3.0](https://img.shields.io/badge/license-GPL--3.0-blue)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/src/branch/master/LICENSE)
[![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/695921d6cc831b859d7878ff93a9bd03e58c056e/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/695921d6cc831b859d7878ff93a9bd03e58c056e/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/695921d6cc831b859d7878ff93a9bd03e58c056e/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/695921d6cc831b859d7878ff93a9bd03e58c056e/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/695921d6cc831b859d7878ff93a9bd03e58c056e/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/695921d6cc831b859d7878ff93a9bd03e58c056e/python.svg)](https://www.python.org/downloads/)
[![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/647c885cba1418fa177a9daa0ac25d7c27300a03/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/647c885cba1418fa177a9daa0ac25d7c27300a03/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/647c885cba1418fa177a9daa0ac25d7c27300a03/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/647c885cba1418fa177a9daa0ac25d7c27300a03/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/647c885cba1418fa177a9daa0ac25d7c27300a03/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/647c885cba1418fa177a9daa0ac25d7c27300a03/python.svg)](https://www.python.org/downloads/)
## Overview
+7 -1
View File
@@ -31,13 +31,14 @@ grm install <host>
├── 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 → integration_test
main.yml → systemd_check → user_setup → rootless_docker → install_runner → prune → healthcheck → integration_test
```
- `install_runner.yml` handles: download, config, validate, register, service
@@ -59,6 +60,7 @@ main.yml → systemd_check → user_setup → rootless_docker → install_runner
| `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 |
@@ -71,6 +73,9 @@ main.yml → systemd_check → user_setup → rootless_docker → install_runner
| `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
@@ -139,6 +144,7 @@ flowchart TD
- 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`
+1 -1
View File
@@ -83,7 +83,7 @@ The platform list is defined in `devx.molecule.platforms` (single source of trut
### CI Test Distribution
CI runs all 6 scenarios x 4 platforms (24 test pairs) distributed across available Gitea Actions runners.
CI runs all 7 scenarios x 4 platforms (28 test pairs) distributed across available Gitea Actions runners.
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.
+90
View File
@@ -10,11 +10,14 @@ GRM provides the following CLI commands for managing Gitea Actions runners. The
| `grm update` | `<host>` | Update the gitea_runner binary on a remote host |
| `grm start` | `<runner_name>` | Start a registered runner |
| `grm stop` | `<runner_name>` | Stop a registered runner |
| `grm restart` | `<runner_name>` | Restart a runner (stop, prune Docker images, start) |
| `grm enable` | `<runner_name>` | Enable a runner to start on boot |
| `grm disable` | `<runner_name>` | Disable and deregister a runner |
| `grm status` | `<runner_name>` | Check the status of a registered runner |
| `grm remove` | `<runner_name>` | Remove a runner completely |
| `grm list` | — | List all registered runners with live status |
| `grm health` | `[runner_name]` | Run health check (Docker, runner service, disk) on one or all runners |
| `grm trigger-workflow` | `<workflow_id>` | Trigger a Gitea Actions workflow via the API |
| `grm --version` | — | Show the installed version |
### Common lifecycle options
@@ -139,6 +142,29 @@ grm stop <runner_name> [options]
| `--key` | `-k` | Override SSH key from registry |
| `--ask-become-pass/--no-ask-become-pass` | — | Prompt for sudo password (default) or skip it |
## restart
Restart a registered Gitea Runner (stop, prune Docker images, start).
```bash
grm restart <runner_name> [options]
```
**Arguments:**
| Argument | Description |
|----------|-------------|
| `runner_name` | Name of the registered runner |
**Options (common lifecycle options):**
| Option | Short | Description |
|--------|-------|-------------|
| `--host` | — | Override host from registry |
| `--user` | `-u` | Override user from registry |
| `--key` | `-k` | Override SSH key from registry |
| `--ask-become-pass/--no-ask-become-pass` | — | Prompt for sudo password (default) or skip it |
## enable
Enable a registered Gitea Runner to start on boot.
@@ -276,6 +302,70 @@ If no runners are registered:
No runners registered. Use 'grm install' to add one.
```
## health
Run a health check on one or all registered runners. Checks Docker daemon status, Gitea runner service status, and disk space usage. Unhealthy services are automatically restarted by the healthcheck script.
```bash
grm health [runner_name] [options]
```
**Arguments:**
| Argument | Description |
|----------|-------------|
| `runner_name` | (optional) Name of the runner to check. If omitted, checks all registered runners. |
**Options (common lifecycle options):**
| Option | Short | Description |
|--------|-------|-------------|
| `--host` | — | Override host from registry |
| `--user` | `-u` | Override user from registry |
| `--key` | `-k` | Override SSH key from registry |
| `--ask-become-pass/--no-ask-become-pass` | — | Prompt for sudo password (default) or skip it |
**Example:**
```bash
grm health
# Check a specific runner:
grm health prod-runner
```
Output shows NAME, HOST, HEALTHY (yes/no), and MESSAGE columns. The command exits with code 1 if any runner is unhealthy.
The health check is also run automatically via a systemd timer installed by the Ansible role. See `ansible/roles/gitea-runner/templates/runner-healthcheck.sh.j2` for the script and `runner-healthcheck.timer.j2` for the timer.
## trigger-workflow
Trigger a Gitea Actions workflow via the API.
```bash
grm trigger-workflow <workflow_id> [options]
grm trigger-workflow --list
```
**Arguments:**
| Argument | Description |
|----------|-------------|
| `workflow_id` | Workflow filename (e.g., `ci.yml`) or ID |
**Options:**
| Option | Description |
|--------|-------------|
| `--list` | List available workflows in the repository |
| `--ref` | Branch or tag to trigger on (default: repository default branch) |
**Example:**
```bash
grm trigger-workflow --list
grm trigger-workflow ci.yml --ref master
```
## --version
Show the installed GRM version.
+3 -3
View File
@@ -34,7 +34,7 @@ ci = [
"build==1.5.0",
"twine==6.2.0",
# Reusable CI/CD and dev tools (auto-merge, pr-review, pre-push checks, etc.)
"devx==0.26.3",
"devx==0.30.0",
]
# Lint and type-checking tools (quality job)
lint = [
@@ -47,14 +47,14 @@ lint = [
]
# Molecule testing (molecule-tests job)
molecule = [
"molecule==26.4.0",
"molecule==26.6.0",
"molecule-docker==2.1.0",
]
# Full dev environment (local development, includes everything)
dev = [
"gitea-runner-manager[ci,lint,molecule]",
# Reusable CI/CD and dev tools (pre-push hooks, create-task, create-pr)
"devx==0.26.3",
"devx==0.30.0",
# Non-Python dev dependency: checkmake (Makefile linter)
# Install via: go install github.com/checkmake/checkmake/cmd/checkmake@latest
]
+1 -1
View File
@@ -1,3 +1,3 @@
"""Gitea Runner Manager — lean CLI for managing Gitea Actions runners."""
__version__ = "0.12.3"
__version__ = "0.14.0"
+38
View File
@@ -442,6 +442,44 @@ def _collect_become_pass(ask_become_pass: bool) -> str | None:
return sys.stdin.readline().strip() or None
@cli.command(name="health", help=_("Run health check on one or all registered runners."))
@click.argument("runner_name", required=False)
@_runner_options
@_handle_errors("Health check failed: {error}")
def health(
runner_name: str | None,
host: str | None,
user: str | None,
key: str | None,
ask_become_pass: bool,
) -> None:
"""Check Docker, runner service, and disk health on remote hosts."""
become_pass = _collect_become_pass(ask_become_pass)
manager = RunnerManager()
results = manager.health(
name=runner_name,
host=host,
user=user,
key=key,
ask_become_pass=ask_become_pass,
become_pass=become_pass,
become_password_file=_get_become_password_file(),
verbose=_get_verbose(),
)
if not results:
click.echo(_("No runners registered. Use 'grm install' to add one."))
return
click.echo(f"{_('NAME'):<18} {_('HOST'):<16} {_('HEALTHY'):<10} {_('MESSAGE')}")
click.echo("-" * 80)
all_healthy = True
for r in results:
if r["healthy"] != "yes":
all_healthy = False
click.echo(f"{r['name']:<18} {r['host']:<16} {r['healthy']:<10} {r['message']}")
if not all_healthy:
raise click.ClickException(_("One or more runners are unhealthy"))
@cli.command(name="list", help=_("List all registered runners with live status."))
@click.option(
"--ask-become-pass/--no-ask-become-pass",
@@ -487,6 +487,68 @@ class RunnerManager:
)
return result
def health(
self,
name: str | None = None,
host: str | None = None,
user: str | None = None,
key: str | None = None,
ask_become_pass: bool = False,
become_pass: str | None = None,
become_password_file: str | None = None,
verbose: bool = False,
) -> list[dict[str, str]]:
"""Run health check on one or all registered runners.
When *name* is provided, checks only that runner. Otherwise,
checks all registered runners. Returns a list of dicts with
``name``, ``host``, ``healthy`` (``"yes"``/``"no"``), and
``message`` keys.
"""
if name:
actual_host, actual_user, actual_key, _gitea_url = self._resolve_runner(name, host, user, key)
entries = [(name, actual_host, actual_user, actual_key)]
else:
entries = [(n, info["host"], info["user"], info.get("key")) for n, info in self._registry.list().items()]
results: list[dict[str, str]] = []
for runner_name, r_host, r_user, r_key in entries:
say(_("Checking health of {name} on {host}", name=runner_name, host=r_host))
healthy = "no"
message = "unknown"
try:
stdout = self._executor.run_ad_hoc(
r_host,
r_user,
r_key,
"shell",
f"sudo -u grm-{runner_name} "
f"XDG_RUNTIME_DIR=/run/user/$(id -u grm-{runner_name}) "
f"systemctl --user start runner-healthcheck.service && "
f"journalctl --user -u runner-healthcheck.service --no-pager -n 1",
become=True,
ask_become_pass=ask_become_pass or become_password_file is not None,
check=False,
become_pass=become_pass,
)
if "OK:" in stdout:
healthy = "yes"
# Extract the OK line
for line in stdout.splitlines():
if "OK:" in line:
message = line.split("OK:", 1)[1].strip()
break
else:
for line in stdout.splitlines():
stripped = line.strip()
if stripped and "CHANGED" not in stripped and "WARNING" not in stripped:
message = stripped
break
except AnsibleError as e:
message = str(e)
results.append({"name": runner_name, "host": r_host, "healthy": healthy, "message": message})
return results
@staticmethod
def _parse_status(stdout: str) -> str:
ansible_noise = (" | CHANGED | ", " | FAILED | ", " | UNREACHABLE | ", "[WARNING]", "ssh:", ">>")
@@ -31,6 +31,14 @@
"ru": "Проверить состояние зарегистрированного Gitea Runner.",
"zh": "检查已注册的 Gitea Runner 状态。"
},
"Checking health of {name} on {host}": {
"bg": "Проверка на здравословното състояние на {name} на {host}",
"de": "Gesundheitsprüfung von {name} auf {host}",
"en": "Checking health of {name} on {host}",
"pl": "Sprawdzanie zdrowia {name} na {host}",
"ru": "Проверка здоровья {name} на {host}",
"zh": "正在检查 {host} 上 {name} 的健康状态"
},
"Checking status of Gitea Runner {name} on {host}": {
"bg": "Проверка на състоянието на Gitea Runner {name} на {host}",
"de": "Prüfe Status von Gitea Runner {name} auf {host}",
@@ -175,6 +183,22 @@
"ru": "Токен админ API Gitea для интеграционного теста (env: CI_GITEA_TOKEN)",
"zh": "Gitea 管理员 API 令牌,用于集成测试(环境变量: CI_GITEA_TOKEN"
},
"HEALTHY": {
"bg": "ЗДРАВ",
"de": "GESUND",
"en": "HEALTHY",
"pl": "ZDROWY",
"ru": "ЗДОРОВ",
"zh": "健康"
},
"Health check failed: {error}": {
"bg": "Проверката на здравословното състояние неуспешна: {error}",
"de": "Gesundheitsprüfung fehlgeschlagen: {error}",
"en": "Health check failed: {error}",
"pl": "Sprawdzanie zdrowia nie powiodło się: {error}",
"ru": "Проверка здоровья не удалась: {error}",
"zh": "健康检查失败: {error}"
},
"HOST": {
"bg": "ХОСТ",
"de": "HOST",
@@ -239,6 +263,14 @@
"ru": "Ошибка списка: {error}",
"zh": "列表失败: {error}"
},
"MESSAGE": {
"bg": "СЪОБЩЕНИЕ",
"de": "MELDUNG",
"en": "MESSAGE",
"pl": "WIADOMOŚĆ",
"ru": "СООБЩЕНИЕ",
"zh": "消息"
},
"NAME": {
"bg": "ИМЕ",
"de": "NAME",
@@ -263,6 +295,14 @@
"ru": "Нет зарегистрированных runners. Используйте 'grm install' чтобы добавить.",
"zh": "没有已注册的 runners。使用 'grm install' 添加一个。"
},
"One or more runners are unhealthy": {
"bg": "Един или повече runners са нездравословни",
"de": "Ein oder mehrere Runner sind fehlerhaft",
"en": "One or more runners are unhealthy",
"pl": "Jeden lub więcej runnerów jest w złym stanie",
"ru": "Один или несколько runners нездоровы",
"zh": "一个或多个 runners 不健康"
},
"Override SSH key from registry": {
"bg": "Замяна на SSH ключа от регистъра",
"de": "SSH-Schlüssel aus Registrierung überschreiben",
@@ -423,6 +463,14 @@
"ru": "Выполнение Ansible playbook",
"zh": "正在运行 Ansible playbook"
},
"Run health check on one or all registered runners.": {
"bg": "Проверка на здравословното състояние на един или всички регистрирани runners.",
"de": "Gesundheitsprüfung für einen oder alle registrierten Runner ausführen.",
"en": "Run health check on one or all registered runners.",
"pl": "Uruchom sprawdzanie zdrowia jednego lub wszystkich zarejestrowanych runnerów.",
"ru": "Проверить здоровье одного или всех зарегистрированных runners.",
"zh": "对一个或所有已注册 runners 运行健康检查。"
},
"SSH user (env: GITEA_RUNNER_USER)": {
"bg": "SSH потребител (env: GITEA_RUNNER_USER)",
"de": "SSH-Benutzer (env: GITEA_RUNNER_USER)",
+69
View File
@@ -852,6 +852,75 @@ class TestCLI:
assert result.exit_code != 0
assert "fail" in result.output
@patch("gitea_runner_manager.cli.RunnerManager")
def test_health_all_healthy(self, mock_manager_class: MagicMock) -> None:
mock_manager = MagicMock()
mock_manager.health.return_value = [
{"name": "r1", "host": "10.0.0.1", "healthy": "yes", "message": "runner healthy, disk at 42%"},
{"name": "r2", "host": "10.0.0.2", "healthy": "yes", "message": "runner healthy, disk at 50%"},
]
mock_manager_class.return_value = mock_manager
runner = CliRunner()
result = runner.invoke(cli, ["health"], input="secret\n")
assert result.exit_code == 0
assert "r1" in result.output
assert "r2" in result.output
assert "yes" in result.output
@patch("gitea_runner_manager.cli.RunnerManager")
def test_health_with_unhealthy(self, mock_manager_class: MagicMock) -> None:
mock_manager = MagicMock()
mock_manager.health.return_value = [
{"name": "r1", "host": "10.0.0.1", "healthy": "yes", "message": "runner healthy, disk at 42%"},
{"name": "r2", "host": "10.0.0.2", "healthy": "no", "message": "Docker daemon down"},
]
mock_manager_class.return_value = mock_manager
runner = CliRunner()
result = runner.invoke(cli, ["health"], input="secret\n")
assert result.exit_code != 0
assert "unhealthy" in result.output.lower()
assert "r2" in result.output
@patch("gitea_runner_manager.cli.RunnerManager")
def test_health_single_runner(self, mock_manager_class: MagicMock) -> None:
mock_manager = MagicMock()
mock_manager.health.return_value = [
{"name": "r1", "host": "10.0.0.1", "healthy": "yes", "message": "runner healthy, disk at 42%"},
]
mock_manager_class.return_value = mock_manager
runner = CliRunner()
result = runner.invoke(cli, ["health", "r1"], input="secret\n")
assert result.exit_code == 0
assert "r1" in result.output
mock_manager.health.assert_called_once()
@patch("gitea_runner_manager.cli.RunnerManager")
def test_health_empty(self, mock_manager_class: MagicMock) -> None:
mock_manager = MagicMock()
mock_manager.health.return_value = []
mock_manager_class.return_value = mock_manager
runner = CliRunner()
result = runner.invoke(cli, ["health"], input="secret\n")
assert result.exit_code == 0
assert "No runners registered" in result.output
@patch("gitea_runner_manager.cli.RunnerManager")
def test_health_error(self, mock_manager_class: MagicMock) -> None:
mock_manager = MagicMock()
from gitea_runner_manager.exceptions import AnsibleError
mock_manager.health.side_effect = AnsibleError("fail")
mock_manager_class.return_value = mock_manager
runner = CliRunner()
result = runner.invoke(cli, ["health"], input="secret\n")
assert result.exit_code != 0
assert "fail" in result.output
@patch("gitea_runner_manager.cli.os.getlogin", side_effect=OSError("no tty"))
@patch("gitea_runner_manager.cli.RunnerManager")
def test_default_user_fallback_on_getlogin_error(
+106
View File
@@ -625,6 +625,112 @@ class TestRunnerManager:
assert manager.list_runners(no_status=True) == []
class TestHealth:
"""Tests for the ``health`` method."""
def test_health_single_runner_healthy(self) -> None:
mock_registry = MagicMock()
mock_registry.get.return_value = {"host": "10.0.0.1", "user": "ubuntu", "key": "/key"}
manager = RunnerManager(registry=mock_registry)
mock_executor = MagicMock()
mock_executor.run_ad_hoc.return_value = "OK: runner healthy, disk at 42%"
manager._executor = mock_executor
results = manager.health(name="r1")
assert len(results) == 1
assert results[0]["name"] == "r1"
assert results[0]["healthy"] == "yes"
assert "runner healthy" in results[0]["message"]
mock_executor.run_ad_hoc.assert_called_once()
def test_health_single_runner_unhealthy(self) -> None:
mock_registry = MagicMock()
mock_registry.get.return_value = {"host": "10.0.0.1", "user": "ubuntu", "key": None}
manager = RunnerManager(registry=mock_registry)
mock_executor = MagicMock()
mock_executor.run_ad_hoc.return_value = "CRITICAL: Docker daemon still down after restart"
manager._executor = mock_executor
results = manager.health(name="r1")
assert len(results) == 1
assert results[0]["healthy"] == "no"
assert "Docker daemon still down" in results[0]["message"]
def test_health_all_runners(self) -> None:
mock_registry = MagicMock()
mock_registry.list.return_value = {
"r1": {"host": "10.0.0.1", "user": "ubuntu", "key": None},
"r2": {"host": "10.0.0.2", "user": "ubuntu", "key": None},
}
manager = RunnerManager(registry=mock_registry)
mock_executor = MagicMock()
mock_executor.run_ad_hoc.side_effect = [
"OK: runner healthy, disk at 42%",
"ERROR: gitea-runner service is inactive, restarting",
]
manager._executor = mock_executor
results = manager.health()
assert len(results) == 2
assert results[0]["healthy"] == "yes"
assert results[1]["healthy"] == "no"
def test_health_empty_registry(self) -> None:
mock_registry = MagicMock()
mock_registry.list.return_value = {}
manager = RunnerManager(registry=mock_registry)
results = manager.health()
assert results == []
def test_health_ansible_error(self) -> None:
mock_registry = MagicMock()
mock_registry.get.return_value = {"host": "10.0.0.1", "user": "ubuntu", "key": None}
manager = RunnerManager(registry=mock_registry)
mock_executor = MagicMock()
mock_executor.run_ad_hoc.side_effect = AnsibleError("ssh unreachable")
manager._executor = mock_executor
results = manager.health(name="r1")
assert len(results) == 1
assert results[0]["healthy"] == "no"
assert "ssh unreachable" in results[0]["message"]
def test_health_with_host_override(self) -> None:
mock_registry = MagicMock()
mock_registry.get.return_value = {"host": "10.0.0.1", "user": "ubuntu", "key": None}
manager = RunnerManager(registry=mock_registry)
mock_executor = MagicMock()
mock_executor.run_ad_hoc.return_value = "OK: runner healthy, disk at 50%"
manager._executor = mock_executor
results = manager.health(name="r1", host="10.0.0.99", user="root")
assert len(results) == 1
assert results[0]["host"] == "10.0.0.99"
call_args = mock_executor.run_ad_hoc.call_args.args
assert call_args[0] == "10.0.0.99"
assert call_args[1] == "root"
def test_health_runner_not_found(self) -> None:
mock_registry = MagicMock()
mock_registry.get.return_value = None
manager = RunnerManager(registry=mock_registry)
with pytest.raises(AnsibleError, match="not found in registry"):
manager.health(name="nonexistent")
def test_health_passes_become_pass(self) -> None:
"""become_pass is forwarded to run_ad_hoc for sudo authentication."""
mock_registry = MagicMock()
mock_registry.get.return_value = {"host": "10.0.0.1", "user": "ubuntu", "key": None}
manager = RunnerManager(registry=mock_registry)
mock_executor = MagicMock()
mock_executor.run_ad_hoc.return_value = "OK: runner healthy, disk at 42%"
manager._executor = mock_executor
manager.health(name="r1", become_pass="s3cr3t")
call_kwargs = mock_executor.run_ad_hoc.call_args.kwargs
assert call_kwargs["become_pass"] == "s3cr3t"
class TestExtraVarsFile:
"""Tests for the ``_extra_vars_file`` context manager."""