Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3538eb0803 | ||
|
|
5a93559b79 | ||
|
|
e30acbe213 | ||
|
|
386f3a88c6 | ||
|
|
e99e9d0ac8 | ||
|
|
ca1d8e5cc0 | ||
|
|
83e800c900 | ||
|
|
be17dc278c | ||
|
|
58b8d5b5de | ||
|
|
48f23b554f | ||
|
|
d32bb40cbd | ||
|
|
34954aa396 | ||
|
|
b9d728334f | ||
|
|
02d7c02a19 | ||
|
|
f861d14f32 | ||
|
|
4359dbdc26 | ||
|
|
fc494f5cc0 | ||
|
|
461ec207ad | ||
|
|
dca82753b2 | ||
|
|
3b952b09b5 | ||
|
|
833792d0ad | ||
|
|
d8a90eaea1 | ||
|
|
358620401d | ||
|
|
32f0ad5cb3 | ||
|
|
4e9d033a40 | ||
|
|
63ef5cdbcf | ||
|
|
8fbe2d3f51 | ||
|
|
a9178714af | ||
|
|
6ffcc38181 | ||
|
|
e5b0e17ec3 | ||
|
|
dc5e1431b5 | ||
|
|
dfa8d77bfa | ||
|
|
a178b1b5d2 | ||
|
|
99529a57af | ||
|
|
0eb033419f | ||
|
|
df4b7f2a19 | ||
|
|
312a706d39 | ||
|
|
ea2f0cc600 | ||
|
|
bd8e13ee66 | ||
|
|
41fc36ff4a | ||
|
|
e585543e9d | ||
|
|
c62c35f5b6 | ||
|
|
4b900ce673 | ||
|
|
cae66e0743 | ||
|
|
0b3a76c550 | ||
|
|
a4d5ba6b70 | ||
|
|
d9ce4e240f | ||
|
|
c1f68f115a | ||
|
|
fdf1293c85 | ||
|
|
01b3f594f7 | ||
|
|
9ffa7a3671 |
@@ -0,0 +1,185 @@
|
||||
---
|
||||
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 & Virtual Environment
|
||||
|
||||
The grm repo is at `/home/emo/dev/ideas/oblachno/grm`. Always `cd` there first.
|
||||
|
||||
All Python tools run inside `.venv`. `make` targets handle activation
|
||||
automatically — always use `make <target>`, never raw `pytest` or `ruff`
|
||||
commands. If `.venv` doesn't exist, run `make setup` first.
|
||||
|
||||
## 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
|
||||
@@ -0,0 +1,155 @@
|
||||
---
|
||||
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 & Virtual Environment
|
||||
|
||||
The grm repo is at `/home/emo/dev/ideas/oblachno/grm`. Always `cd` there first.
|
||||
|
||||
All Python tools run inside `.venv`. `make` targets handle activation
|
||||
automatically — always use `make <target>`, never raw `pytest` or `ruff`
|
||||
commands. If `.venv` doesn't exist, run `make setup` 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
|
||||
.venv/bin/python -m devx.tools.check_pyproject_deps
|
||||
.venv/bin/python -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
|
||||
@@ -0,0 +1,138 @@
|
||||
---
|
||||
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 & Virtual Environment
|
||||
|
||||
The grm repo is at `/home/emo/dev/ideas/oblachno/grm`. Always `cd` there first.
|
||||
|
||||
All Python tools run inside `.venv`. `make` targets handle activation
|
||||
automatically — always use `make <target>`, never raw `pytest` or `ruff`
|
||||
commands. If `.venv` doesn't exist, run `make setup` 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
|
||||
.venv/bin/python -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
|
||||
.venv/bin/python -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
|
||||
.venv/bin/python -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
|
||||
@@ -0,0 +1,158 @@
|
||||
---
|
||||
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 & Virtual Environment
|
||||
|
||||
The grm repo is at `/home/emo/dev/ideas/oblachno/grm`. Always `cd` there first.
|
||||
|
||||
All Python tools run inside `.venv`. `make` targets handle activation
|
||||
automatically — always use `make <target>`, never raw `pytest` or `ruff`
|
||||
commands. If `.venv` doesn't exist, run `make setup` 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
|
||||
@@ -0,0 +1,154 @@
|
||||
---
|
||||
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 & Virtual Environment
|
||||
|
||||
The grm repo is at `/home/emo/dev/ideas/oblachno/grm`. Always `cd` there first.
|
||||
|
||||
All Python tools run inside `.venv`. `make` targets handle activation
|
||||
automatically — always use `make <target>`, never raw `pytest` or `ruff`
|
||||
commands. If `.venv` doesn't exist, run `make setup` 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
|
||||
@@ -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`)
|
||||
@@ -0,0 +1,92 @@
|
||||
# testing-and-debugging
|
||||
|
||||
Make targets for testing, debugging, and CI investigation. **Use these
|
||||
instead of raw `pytest`, `ruff`, or `molecule` commands.**
|
||||
|
||||
## Why Make Targets
|
||||
|
||||
Make targets encapsulate the correct venv activation, PYTHONPATH, env
|
||||
vars, and flags. Running raw commands bypasses venv activation and
|
||||
produces false failures (missing dependencies, wrong Python version).
|
||||
|
||||
## Unit Tests
|
||||
|
||||
| Task | Command | Notes |
|
||||
|------|---------|-------|
|
||||
| Run all unit tests | `make test-unit` | Fast, no coverage |
|
||||
| Run with coverage | `make pytest-cov` | **Required before push** — enforces 100% |
|
||||
| Run single test | `make pytest-cov TEST=tests/test_foo.py::test_bar` | |
|
||||
|
||||
## Linting
|
||||
|
||||
| Task | Command | Notes |
|
||||
|------|---------|-------|
|
||||
| Full lint | `make lint-all` | ruff + pyright + bandit + ansible-lint + checkmake + actionlint |
|
||||
| Ruff only | `make lint-ruff` | |
|
||||
| Type check | `make typecheck` | pyright |
|
||||
| Bandit | `make lint-bandit` | Security linter |
|
||||
| Workflow lint | `make workflow-check` | actionlint + act_runner dry-run |
|
||||
|
||||
## Molecule Tests
|
||||
|
||||
| Task | Command | Notes |
|
||||
|------|---------|-------|
|
||||
| All scenarios | `make molecule` | All 6 scenarios on Ubuntu 22.04 |
|
||||
| All platforms | `make molecule-all` | All 6 scenarios on all 4 OSes |
|
||||
| Parallel | `make molecule-all-parallel` | MOLECULE_JOBS=4 |
|
||||
|
||||
## Pre-Push Verification
|
||||
|
||||
**Before pushing any branch:**
|
||||
|
||||
```bash
|
||||
make pre-push
|
||||
```
|
||||
|
||||
This runs `lint-all` + `pytest-cov`. The pre-push git hook only
|
||||
validates the Vikunja task exists — it does NOT run tests. You must
|
||||
run `make pre-push` manually.
|
||||
|
||||
## CI Failure Investigation
|
||||
|
||||
When investigating a CI failure:
|
||||
|
||||
1. **Fetch logs via MCP** — use `mcp_call_tool` with gitea server,
|
||||
`actions_run_read` method, `download_job_log` tool
|
||||
2. **Reproduce locally** — use `make pytest-cov` or `make lint-ci`
|
||||
depending on which CI job failed
|
||||
3. **Never run raw pytest** — always use the make target
|
||||
|
||||
## Virtual Environment
|
||||
|
||||
All commands run inside `.venv`. `make` targets handle activation
|
||||
automatically. For raw commands (rare), activate first:
|
||||
|
||||
```bash
|
||||
source activate.sh # bash/zsh
|
||||
source activate.fish # fish
|
||||
source activate.zsh # zsh
|
||||
```
|
||||
|
||||
If `.venv` doesn't exist, run `make setup` first.
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
### Coverage Verification Before Push
|
||||
|
||||
**Always run `make pytest-cov` before pushing** — CI enforces 100%
|
||||
coverage and will fail the PR if any lines are uncovered. This is the
|
||||
most common cause of CI quality job failures after code changes. The
|
||||
pre-push git hook only validates Vikunja task existence, not tests.
|
||||
|
||||
### API Response Type Checking
|
||||
|
||||
Never use `is True`/`is False` identity checks on API response values.
|
||||
Many APIs return boolean values as strings (`"true"`/`"false"`). Use
|
||||
string comparison or truthy/falsy helpers instead.
|
||||
|
||||
### Time Mocking in Tests
|
||||
|
||||
Always mock `time.sleep` and `time.monotonic` in unit tests using
|
||||
`@patch` decorators. Real sleep calls make tests slow and exceed test
|
||||
speed limits.
|
||||
@@ -24,6 +24,9 @@ GITEA_REGISTRATION_TOKEN=your-registration-token
|
||||
# Default SSH user for remote hosts (optional, overrides --user)
|
||||
# GITEA_RUNNER_USER=ubuntu
|
||||
|
||||
# Repository for grm trigger-workflow (optional, default: oblachno-oss/grm)
|
||||
# GRM_REPO=oblachno-oss/grm
|
||||
|
||||
# Default SSH private key path (optional, overrides --key)
|
||||
# GITEA_RUNNER_KEY=~/.ssh/id_ed25519
|
||||
|
||||
@@ -47,6 +50,10 @@ GITEA_REGISTRATION_TOKEN=your-registration-token
|
||||
# Used by PIP_INSTALL to configure PIP_EXTRA_INDEX_URL
|
||||
CI_GITEA_USERNAME=emil
|
||||
|
||||
# Vikunja API token (required for `make create-task` dev workflow)
|
||||
# Generate at: Vikunja → Settings → API Tokens
|
||||
# VIKUNJA_TOKEN=your-vikunja-api-token
|
||||
|
||||
# devx configuration (GRM-specific overrides)
|
||||
# Task prefix for Vikunja task IDs
|
||||
DEVX_TASK_PREFIX=GRM
|
||||
|
||||
+73
-17
@@ -20,36 +20,42 @@ jobs:
|
||||
env:
|
||||
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
|
||||
CI_GITEA_USERNAME: ${{ vars.CI_GITEA_USERNAME }}
|
||||
run: make setup-image EXTRAS=lint
|
||||
run: make setup-image EXTRAS=ci,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:
|
||||
PYTHONPATH: src
|
||||
run: |
|
||||
. .venv/bin/activate 2>/dev/null || true
|
||||
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
|
||||
@@ -79,7 +85,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
|
||||
|
||||
@@ -105,12 +111,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'
|
||||
@@ -134,7 +171,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 }}" \
|
||||
@@ -147,8 +184,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
|
||||
@@ -158,7 +197,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:
|
||||
@@ -166,7 +205,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" \
|
||||
@@ -174,7 +213,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"
|
||||
@@ -188,6 +227,7 @@ jobs:
|
||||
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
|
||||
CI_GITEA_USERNAME: ${{ vars.CI_GITEA_USERNAME }}
|
||||
RUN_ID: ${{ github.run_id }}
|
||||
ANSIBLE_INJECT_INVOCATION: "1"
|
||||
JOB_NAME: ${{ github.job }}
|
||||
MATRIX_INDEX: ${{ matrix.runner-index }}
|
||||
GITEA_REPOSITORY: ${{ github.repository }}
|
||||
@@ -215,7 +255,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 }}"
|
||||
@@ -225,11 +265,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')
|
||||
@@ -249,6 +290,21 @@ jobs:
|
||||
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
|
||||
CI_GITEA_USERNAME: ${{ vars.CI_GITEA_USERNAME }}
|
||||
run: make setup-image EXTRAS=ci
|
||||
- name: Post approval review
|
||||
env:
|
||||
CI_GITEA_TOKEN: ${{ secrets.REVIEW_GITEA_TOKEN }}
|
||||
PR_NUMBER: ${{ github.event.number }}
|
||||
REPOSITORY: ${{ github.repository }}
|
||||
PYTHONPATH: src
|
||||
run: |
|
||||
. .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, pre-merge-check)."
|
||||
- name: Squash merge with task ID
|
||||
env:
|
||||
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
|
||||
@@ -261,7 +317,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" \
|
||||
|
||||
@@ -29,6 +29,7 @@ name: Post-merge
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
|
||||
@@ -55,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:
|
||||
@@ -78,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
|
||||
@@ -113,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
|
||||
@@ -151,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 }}" \
|
||||
@@ -190,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()
|
||||
@@ -230,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()
|
||||
@@ -267,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()
|
||||
@@ -303,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()
|
||||
|
||||
+31
-7
@@ -57,6 +57,37 @@ repos:
|
||||
pass_filenames: false
|
||||
stages: [pre-commit]
|
||||
|
||||
- id: checkmake
|
||||
name: checkmake Makefile linter
|
||||
entry: make checkmake
|
||||
language: system
|
||||
files: ^Makefile$
|
||||
pass_filenames: false
|
||||
stages: [pre-commit]
|
||||
|
||||
- id: check-test-speed
|
||||
name: unit test speed check
|
||||
entry: .venv/bin/python -m devx.tools.check_test_speed --max-seconds 4 --max-single-seconds 0.5
|
||||
language: system
|
||||
types: [python]
|
||||
pass_filenames: false
|
||||
stages: [pre-commit]
|
||||
|
||||
- id: check-translations
|
||||
name: translation completeness check
|
||||
entry: env PYTHONPATH=src .venv/bin/python -m devx.ci.check_translations --translations src/gitea_runner_manager/translations.json
|
||||
language: system
|
||||
files: ^src/gitea_runner_manager/translations\.json$
|
||||
pass_filenames: false
|
||||
stages: [pre-commit]
|
||||
|
||||
- id: lint-docs
|
||||
name: documentation lint check
|
||||
entry: env PYTHONPATH=src .venv/bin/python -m devx.ci.lint_docs --root .
|
||||
language: system
|
||||
pass_filenames: false
|
||||
stages: [pre-commit]
|
||||
|
||||
- id: pytest-cov
|
||||
name: pytest with 100% coverage
|
||||
entry: make pytest-cov
|
||||
@@ -64,10 +95,3 @@ repos:
|
||||
types: [python]
|
||||
pass_filenames: false
|
||||
stages: [pre-push]
|
||||
|
||||
- id: commit-msg
|
||||
name: validate commit message
|
||||
entry: env PYTHONPATH=src .venv/bin/python -m devx.ci.validate_commit_msg
|
||||
language: system
|
||||
stages: [commit-msg]
|
||||
pass_filenames: true
|
||||
|
||||
@@ -1,5 +1,19 @@
|
||||
# AGENTS.md — Project Conventions for GRM
|
||||
|
||||
## Virtual Environment
|
||||
|
||||
All Python tools, tests, and scripts run inside a standard `.venv` directory.
|
||||
Activate it before running any non-`make` command:
|
||||
|
||||
```bash
|
||||
source activate.sh # bash/zsh
|
||||
source activate.fish # fish
|
||||
source activate.zsh # zsh
|
||||
```
|
||||
|
||||
If `.venv` doesn't exist, run `make setup` first. The `make` targets handle
|
||||
venv activation automatically — always prefer `make <target>` over raw commands.
|
||||
|
||||
## Build & Test Commands
|
||||
|
||||
```bash
|
||||
@@ -98,8 +112,7 @@ docs: update README
|
||||
|
||||
### 6. Review the PR (Mandatory — Before Adding ready-to-merge Label)
|
||||
|
||||
**Review checklist:** Every PR is reviewed against
|
||||
[REVIEW_CHECKLIST.md](REVIEW_CHECKLIST.md) — 13 categories covering
|
||||
**Review checklist:** Every PR is reviewed against 13 categories covering
|
||||
architecture, code quality, security, i18n, testing, performance,
|
||||
UX, documentation, workflow compliance, maintainability, resource
|
||||
management, backwards compatibility, and logging.
|
||||
@@ -120,11 +133,11 @@ the **[auto]** items in the checklist:
|
||||
- Commit conventions (conventional commit format on PR commits)
|
||||
|
||||
The automated review posts inline comments on specific lines and
|
||||
includes a link to the full checklist. The agent **must** address all
|
||||
includes a summary of the checklist categories. The agent **must** address all
|
||||
`REQUEST_CHANGES` issues before proceeding.
|
||||
|
||||
**Manual review (agent):** After the automated review passes, the agent
|
||||
must go through **every category** in `REVIEW_CHECKLIST.md` and verify
|
||||
must go through **every category** listed above and verify
|
||||
the **[manual]** items by reviewing the full diff
|
||||
(`git diff master...HEAD`).
|
||||
|
||||
@@ -145,7 +158,7 @@ an approval review with `--checklist-confirmed` and `--checklist-categories`:
|
||||
CI_GITEA_TOKEN=<token> python -m devx.ci.pr_review <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. Architecture: <summary>. Security: <summary>. Tests: <summary>. Docs: <summary>."
|
||||
--body "All 13 checklist categories verified. Architecture: <summary>. Security: <summary>. Tests: <summary>. Docs: <summary>."
|
||||
```
|
||||
|
||||
The `--checklist-confirmed` flag is **required** for APPROVE events —
|
||||
@@ -164,6 +177,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.
|
||||
@@ -172,6 +191,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
|
||||
@@ -243,32 +266,32 @@ 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
|
||||
- `tests/**` — Test files
|
||||
- `AGENTS.md`, `README.md`, `CHANGELOG.md`, `TROUBLESHOOTING.md`, `CONTRIBUTING.md`, `CODE_OF_CONDUCT.md`, `REVIEW_CHECKLIST.md` — Project docs
|
||||
- `AGENTS.md`, `README.md`, `CHANGELOG.md`, `TROUBLESHOOTING.md`, `CONTRIBUTING.md` — Project docs
|
||||
- `Makefile`, `cliff.toml`, `uv.lock` — Build tooling
|
||||
- `.pre-commit-config.yaml`, `.ruff.toml`, `.ansible-lint`, `.checkmake.ini`, `.editorconfig` — Lint config
|
||||
- `.env.example`, `.gitignore`, `.gitattributes` — Config
|
||||
- `.pre-commit-config.yaml`, `.ansible-lint`, `.checkmake.ini` — Lint config (ruff config is in `pyproject.toml`)
|
||||
- `.env.example`, `.gitignore` — Config
|
||||
- `.devin/**` — Agent/CI tooling config
|
||||
- `hooks/**` — Git hooks
|
||||
- `activate.sh`, `activate.fish`, `activate.zsh` — Generated venv scripts
|
||||
|
||||
**User-facing paths** (tool changes → release needed) — everything else:
|
||||
- `src/gitea_runner_manager/**` — Python CLI source (except `__init__.py` and `api_clients.py`)
|
||||
- `src/gitea_runner_manager/**` — Python CLI source (except `__init__.py`)
|
||||
- `ansible/**` — Ansible role
|
||||
- `pyproject.toml` — Package metadata
|
||||
- Any new file type not in the allowlist
|
||||
|
||||
**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
|
||||
@@ -284,7 +307,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
|
||||
@@ -398,7 +421,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
|
||||
|
||||
@@ -410,6 +433,50 @@ Change classification is config-driven via `[tool.devx.classify]` in `pyproject.
|
||||
- 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`)
|
||||
|
||||
### Testing Conventions
|
||||
|
||||
- **Always run `make pytest-cov` before pushing** — CI enforces 100%
|
||||
coverage and will fail the PR if any lines are uncovered. The pre-push
|
||||
hook only validates Vikunja task existence, not tests.
|
||||
- **Never use `is True`/`is False` identity checks on API response
|
||||
values** — many APIs return boolean values as strings (`"true"`/
|
||||
`"false"`). Use string comparison or truthy/falsy helpers instead.
|
||||
- **Always mock `time.sleep` and `time.monotonic` in unit tests** — real
|
||||
sleep calls make tests slow and exceed test speed limits. Use
|
||||
`@patch("time.sleep")` and `@patch("time.monotonic")` decorators.
|
||||
- **Extract complex inline shell from workflows to tested Python tools**
|
||||
— SSH loops, curl polling, docker exec chains, and multi-line
|
||||
if/then/else shell blocks should be Python scripts in `scripts/`
|
||||
with unit tests. Simple variable checks and venv activation are fine
|
||||
as inline shell.
|
||||
|
||||
### Container-Level Fix Verification (Mandatory)
|
||||
|
||||
**Rule:** Before pushing any fix that modifies container state (CA certs,
|
||||
config files, installed packages, daemon restarts), reproduce the exact
|
||||
sequence locally with the actual Docker image. Do not push to CI as the
|
||||
first test.
|
||||
|
||||
This is a hard rule, not a suggestion. CI cycles take 20+ minutes and
|
||||
ephemeral staging VMs are destroyed after each run, making interactive
|
||||
debugging impossible. A local reproduction takes 30 seconds and catches
|
||||
silent failures immediately.
|
||||
|
||||
**Procedure:**
|
||||
1. `docker pull <actual_image>`
|
||||
2. `docker run -d --name <test> ...` and wait for it to start
|
||||
3. Run the exact commands from the Ansible task or script
|
||||
4. Verify the state change took effect
|
||||
5. Clean up: `docker rm -f <test>`
|
||||
|
||||
### Verified State Modification (Mandatory)
|
||||
|
||||
Ansible tasks that modify container state with `changed_when: false`
|
||||
MUST include a post-task verification step that confirms the state
|
||||
change took effect. `changed_when: false` suppresses both change
|
||||
detection AND failure visibility — a task can silently do nothing and
|
||||
report `ok`.
|
||||
|
||||
## Ansible Role Structure
|
||||
|
||||
```
|
||||
@@ -423,10 +490,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`
|
||||
@@ -476,3 +545,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"`.
|
||||
|
||||
|
||||
@@ -2,6 +2,74 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [0.14.2] - 2026-07-05
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Add pre-commit hooks for quality gates matching CI
|
||||
|
||||
## [0.14.1] - 2026-07-01
|
||||
|
||||
### Refactor
|
||||
|
||||
- Align venv management to devx.mak targets
|
||||
|
||||
## [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
|
||||
|
||||
- Fix wiki link URLs, heading hierarchy, quote pip install vars
|
||||
- Improve runner service stability and deregistration
|
||||
|
||||
## [0.12.2] - 2026-06-28
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Bump devx to 0.26.3 (latest with pinned deps)
|
||||
|
||||
## [0.12.1] - 2026-06-28
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Add approval step to auto-merge workflow using REVIEW_GITEA_TOKEN
|
||||
|
||||
## [0.12.0] - 2026-06-28
|
||||
|
||||
### Features
|
||||
|
||||
- Upgrade all dependencies, add trigger-workflow command
|
||||
|
||||
## [0.11.1] - 2026-06-28
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Makefile HOST/NAME requirement errors, add restart and list targets
|
||||
|
||||
## [0.11.0] - 2026-06-28
|
||||
|
||||
### Features
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# Contributing to GRM
|
||||
|
||||
For the full contributing guide, see the [Contributing wiki page](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/wiki/Contributing).
|
||||
|
||||
Thank you for contributing to Gitea Runner Manager (GRM)!
|
||||
|
||||
## Branch Naming
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
.PHONY: all setup setup-ci setup-quality setup-molecule setup-release setup-image install update lint ansible-lint makefile-lint lint-all lint-ruff lint-format lint-bandit lint-deps typecheck checkmake install-hooks test test-unit pytest-cov molecule molecule-all test-all clean workflow-lint workflow-dryrun workflow-check install-tools
|
||||
.PHONY: all setup setup-ci setup-quality setup-molecule setup-release setup-image install update lint ansible-lint makefile-lint lint-all lint-ruff lint-format lint-bandit lint-deps typecheck checkmake install-hooks test test-unit pytest-cov molecule molecule-all test-all clean workflow-lint workflow-dryrun workflow-check install-tools check-api-identity-checks
|
||||
.PHONY: configure-gitea-pypi
|
||||
.PHONY: create-task create-pr push-with-pr git-push
|
||||
|
||||
@@ -25,6 +25,18 @@ DEVX_LINT_PATHS := src/ scripts/ tests/
|
||||
DEVX_MAK := $(shell $(BIN)/python -c \
|
||||
"from pathlib import Path; import devx; print(Path(devx.__file__).parent / 'make' / 'devx.mak')" \
|
||||
2>/dev/null)
|
||||
# Fallback: when the venv doesn't exist yet, try system python3 or /opt/venv.
|
||||
# In CI, /opt/venv has devx pre-installed; locally, devx may be in system python.
|
||||
ifeq ($(strip $(DEVX_MAK)),)
|
||||
DEVX_MAK := $(shell python3 -c \
|
||||
"from pathlib import Path; import devx; print(Path(devx.__file__).parent / 'make' / 'devx.mak')" \
|
||||
2>/dev/null)
|
||||
endif
|
||||
ifeq ($(strip $(DEVX_MAK)),)
|
||||
DEVX_MAK := $(shell /opt/venv/bin/python -c \
|
||||
"from pathlib import Path; import devx; print(Path(devx.__file__).parent / 'make' / 'devx.mak')" \
|
||||
2>/dev/null)
|
||||
endif
|
||||
-include $(DEVX_MAK)
|
||||
|
||||
# Full setup for local development (all deps, tools, collections, hooks)
|
||||
@@ -77,28 +89,14 @@ setup-image:
|
||||
pip install -e .$(if $(EXTRAS),[$(EXTRAS)],); \
|
||||
else echo "[setup-image] /opt/venv not found — falling back to setup-ci"; $(MAKE) setup-ci; fi
|
||||
|
||||
# Helper: run pip install with Gitea registry configured
|
||||
# Usage: $(PIP_INSTALL) install -e '.[ci,lint]'
|
||||
PIP_INSTALL := if [ -z "$$CI_GITEA_TOKEN" ]; then . ./.env 2>/dev/null; fi; \
|
||||
CI_GITEA_TOKEN="$$CI_GITEA_TOKEN"; \
|
||||
if [ -n "$$CI_GITEA_TOKEN" ]; then export PIP_EXTRA_INDEX_URL="https://$$CI_GITEA_USERNAME:$$CI_GITEA_TOKEN@git.oblachno.oblachno.fyi/api/packages/oblachno-oss/pypi/simple/"; fi; \
|
||||
$(BIN)/pip
|
||||
|
||||
$(VENV)/bin/activate:
|
||||
@python3 -c "import sys; v=sys.version_info; assert v >= (3, 12), f'Python 3.12+ required, found {v.major}.{v.minor}'; print(f'Python {v.major}.{v.minor}.{v.micro} OK')"
|
||||
$(PYTHON) -m venv $(VENV)
|
||||
$(BIN)/pip install --upgrade pip setuptools wheel
|
||||
|
||||
.env:
|
||||
@if [ ! -f .env ]; then \
|
||||
cp .env.example .env; \
|
||||
echo "Created .env from .env.example — please edit it with your credentials."; \
|
||||
fi
|
||||
|
||||
activate-scripts: $(VENV)/bin/activate
|
||||
@test -f activate.sh || (echo '#!/usr/bin/env bash' > activate.sh && echo 'source "$$(cd "$$(dirname "$${BASH_SOURCE[0]}")" && pwd)/.venv/bin/activate"' >> activate.sh && chmod +x activate.sh)
|
||||
@test -f activate.fish || (echo '#!/usr/bin/env fish' > activate.fish && echo 'set -l script_dir (dirname (status --current-filename))' >> activate.fish && echo 'source "$$script_dir/.venv/bin/activate.fish"' >> activate.fish && chmod +x activate.fish)
|
||||
@test -f activate.zsh || (echo '#!/usr/bin/env zsh' > activate.zsh && echo '0="$${ZERO:-$${0:#$$ZSH_ARGZERO}}"' >> activate.zsh && echo '0="$${$${(M)0:#/*}:-$$PWD/$$0}"' >> activate.zsh && echo 'source "$${0:A:h}/.venv/bin/activate"' >> activate.zsh && chmod +x activate.zsh)
|
||||
# venv, .env, activate-scripts, and PIP_INSTALL are provided by devx.mak
|
||||
# (devx-venv, devx-env, devx-activate-scripts, DEVX_PIP_INSTALL)
|
||||
# Aliases for convenience and backward compatibility:
|
||||
.PHONY: venv activate-scripts
|
||||
PIP_INSTALL := $(DEVX_PIP_INSTALL)
|
||||
venv: devx-venv
|
||||
.env: devx-env
|
||||
activate-scripts: devx-activate-scripts
|
||||
|
||||
install:
|
||||
@if [ -z "$(HOST)" ]; then echo "HOST is required. Example: make install HOST=192.168.1.10"; exit 1; fi
|
||||
@@ -109,28 +107,35 @@ update:
|
||||
$(BIN)/grm update $(HOST) $(if $(USER),--user $(USER),) $(if $(KEY),--key $(KEY),) $(if $(VERSION),--version $(VERSION),) $(if $(ASK_BECOME_PASS),--ask-become-pass,)
|
||||
|
||||
start:
|
||||
@if [ -z "$(HOST)" ]; then echo "HOST is required. Example: make start HOST=192.168.1.10"; exit 1; fi
|
||||
@if [ -z "$(NAME)" ]; then echo "NAME is required. Example: make start NAME=runner1"; exit 1; fi
|
||||
$(BIN)/grm start $(NAME) $(if $(HOST),--host $(HOST),) $(if $(USER),--user $(USER),) $(if $(ASK_BECOME_PASS),--ask-become-pass,)
|
||||
|
||||
stop:
|
||||
@if [ -z "$(HOST)" ]; then echo "HOST is required. Example: make stop HOST=192.168.1.10"; exit 1; fi
|
||||
@if [ -z "$(NAME)" ]; then echo "NAME is required. Example: make stop NAME=runner1"; exit 1; fi
|
||||
$(BIN)/grm stop $(NAME) $(if $(HOST),--host $(HOST),) $(if $(USER),--user $(USER),) $(if $(ASK_BECOME_PASS),--ask-become-pass,)
|
||||
|
||||
restart:
|
||||
@if [ -z "$(NAME)" ]; then echo "NAME is required. Example: make restart NAME=runner1"; exit 1; fi
|
||||
$(BIN)/grm restart $(NAME) $(if $(HOST),--host $(HOST),) $(if $(USER),--user $(USER),) $(if $(ASK_BECOME_PASS),--ask-become-pass,)
|
||||
|
||||
enable:
|
||||
@if [ -z "$(HOST)" ]; then echo "HOST is required. Example: make enable HOST=192.168.1.10"; exit 1; fi
|
||||
@if [ -z "$(NAME)" ]; then echo "NAME is required. Example: make enable NAME=runner1"; exit 1; fi
|
||||
$(BIN)/grm enable $(NAME) $(if $(HOST),--host $(HOST),) $(if $(USER),--user $(USER),) $(if $(ASK_BECOME_PASS),--ask-become-pass,)
|
||||
|
||||
disable:
|
||||
@if [ -z "$(HOST)" ]; then echo "HOST is required. Example: make disable HOST=192.168.1.10"; exit 1; fi
|
||||
@if [ -z "$(NAME)" ]; then echo "NAME is required. Example: make disable NAME=runner1"; exit 1; fi
|
||||
$(BIN)/grm disable $(NAME) $(if $(HOST),--host $(HOST),) $(if $(USER),--user $(USER),) $(if $(TOKEN),--token $(TOKEN),) $(if $(ASK_BECOME_PASS),--ask-become-pass,)
|
||||
|
||||
status:
|
||||
@if [ -z "$(HOST)" ]; then echo "HOST is required. Example: make status HOST=192.168.1.10"; exit 1; fi
|
||||
@if [ -z "$(NAME)" ]; then echo "NAME is required. Example: make status NAME=runner1"; exit 1; fi
|
||||
$(BIN)/grm status $(NAME) $(if $(HOST),--host $(HOST),) $(if $(USER),--user $(USER),) $(if $(ASK_BECOME_PASS),--ask-become-pass,)
|
||||
|
||||
remove:
|
||||
@if [ -z "$(HOST)" ]; then echo "HOST is required. Example: make remove HOST=192.168.1.10"; exit 1; fi
|
||||
$(BIN)/grm remove $(NAME) $(if $(HOST),--host $(HOST),) $(if $(USER),--user $(USER),) $(if $(TOKEN),--token $(TOKEN),) $(if $(ASK_BECOME_PASS),--ask-become-pass,)
|
||||
@if [ -z "$(NAME)" ]; then echo "NAME is required. Example: make remove NAME=runner1"; exit 1; fi
|
||||
$(BIN)/grm remove $(NAME) $(if $(HOST),--host $(HOST),) $(if $(USER),--user $(USER),) $(if $(TOKEN),--token $(TOKEN),) $(if $(FORCE),--force,) $(if $(ASK_BECOME_PASS),--ask-become-pass,)
|
||||
|
||||
list:
|
||||
$(BIN)/grm list $(if $(NO_STATUS),--no-status,) $(if $(ASK_BECOME_PASS),--ask-become-pass,)
|
||||
|
||||
# --- Aliases to devx.mak targets ----------------------------------------------
|
||||
lint-ruff: devx-lint-ruff
|
||||
@@ -168,7 +173,10 @@ makefile-lint:
|
||||
echo "checkmake not found, skipping Makefile lint"; \
|
||||
fi
|
||||
|
||||
lint-all: lint ansible-lint makefile-lint workflow-lint
|
||||
lint-all: lint ansible-lint makefile-lint workflow-lint check-api-identity-checks
|
||||
|
||||
check-api-identity-checks:
|
||||
@$(BIN)/python -m devx.tools.check_api_identity_checks
|
||||
|
||||
test-integration:
|
||||
$(BIN)/pytest tests/integration/ -v --no-cov
|
||||
|
||||
@@ -8,12 +8,12 @@ Each runner runs in an isolated **rootless Docker** environment under a dedicate
|
||||
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/src/branch/master/LICENSE)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/wiki)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/releases)
|
||||
[](https://www.python.org/downloads/)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/wiki)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/releases)
|
||||
[](https://www.python.org/downloads/)
|
||||
|
||||
## Why GRM?
|
||||
|
||||
@@ -148,8 +148,12 @@ GRM provides a single `grm` command with subcommands for the full runner lifecyc
|
||||
| `grm disable <name>` | Disable and deregister a runner |
|
||||
| `grm status <name>` | Check the status of a registered runner |
|
||||
| `grm remove <name>` | Remove a runner completely (with remote cleanup) |
|
||||
| `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 |
|
||||
|
||||
All lifecycle commands (`start`, `stop`, `restart`, `enable`, `disable`, `status`, `remove`) work by runner name and pull connection details from the local registry. You can override any stored value with `--host`, `--user`, or `--key`.
|
||||
@@ -360,8 +364,7 @@ grm install <host>
|
||||
| `executor.py` | Ansible subprocess execution with log capture |
|
||||
| `registry.py` | Local JSON runner registry at `~/.local/share/grm/runners.json` |
|
||||
| `i18n.py` | Internationalisation (en, bg, de, ru, zh, pl) |
|
||||
| `exceptions.py` | Custom exceptions (`GRMError`, `AnsibleError`, `APIError`) |
|
||||
| `config.py` | Configuration constants (API URLs, repo owner/name) |
|
||||
| `exceptions.py` | Custom exceptions (`GRMError`, `AnsibleError`) |
|
||||
| `logging_config.py` | Logging to `~/.local/state/grm/logs/grm.log` |
|
||||
| `report.py` | Operation report tracking with step status |
|
||||
| `ui.py` | Colorised console output via Click |
|
||||
|
||||
+1
-15
@@ -1,17 +1,3 @@
|
||||
# Troubleshooting
|
||||
|
||||
| Symptom | Likely Cause | Solution |
|
||||
|---------|-------------|----------|
|
||||
| Pre-commit rejects commit message | Missing conventional format or GRM-N prefix present | Use `feat: description` format without `GRM-N:` |
|
||||
| `make molecule` fails with `runner_name is undefined` | Verify playbook missing variable | Fixed in Phase 1.1; ensure you're on latest master |
|
||||
| CI molecule job fails | Docker not available on runner host | Ensure Gitea runner host has Docker installed and running |
|
||||
| Auto-merge doesn't trigger | Label not exactly `ready-to-merge` or CI checks not all green | Verify label spelling; check CI status |
|
||||
| Vikunja task not updated after merge | VIKUNJA_TOKEN expired or task ID missing from commit | Regenerate token; verify merge commit has `GRM-N:` prefix |
|
||||
| Post-merge can't find Vikunja task | Task not in project 6 or identifier mismatch | Verify task exists in Vikunja project 6 with correct identifier |
|
||||
| `make pytest-cov` fails | Coverage below 100% | Add tests for new code paths |
|
||||
| `devx.tools.configure_repo` fails | CI_GITEA_TOKEN missing or invalid | Set token with repo admin scope and re-run |
|
||||
| `configure_repo` sets wrong status checks | Stale `BRANCH_PROTECTION_CONFIG` | Updated to include `(pull_request)` suffix; re-run `configure_repo` |
|
||||
| Token visible in `ps aux` during install | Old version passed tokens via command line | Fixed: tokens now passed via temp file with `0600` permissions |
|
||||
| `remove-runner.yml` leaves lingering enabled | Old version didn't disable lingering | Fixed: now runs `loginctl disable-linger` and removes subuid/subgid |
|
||||
| apt cache update always reports `changed` | `cache_valid_time: 0` forced update every run | Fixed: changed to `cache_valid_time: 3600` |
|
||||
| Prune/service templates created even when `docker_rootless_setup: false` | Template tasks not guarded | Fixed: template creation now guarded by `docker_rootless_setup` |
|
||||
See the [Troubleshooting guide](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/wiki/Troubleshooting) in the wiki.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
collections:
|
||||
- name: community.general
|
||||
version: ">=13.0.1"
|
||||
version: "==13.1.0"
|
||||
- name: ansible.posix
|
||||
version: ">=1.5.4"
|
||||
version: "==2.2.0"
|
||||
- name: community.docker
|
||||
version: "==5.2.1"
|
||||
|
||||
@@ -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,17 @@ 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.
|
||||
gitea_admin_token: ""
|
||||
|
||||
# Removal defaults
|
||||
remove_systemd_template: true
|
||||
remove_runner_user: true
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -30,7 +30,10 @@
|
||||
that:
|
||||
- "'Type=simple' in service_template.content | b64decode"
|
||||
- "'ExecStart={{ gitea_runner_binary_path }}' in service_template.content | b64decode"
|
||||
- "'Restart=on-failure' in service_template.content | b64decode"
|
||||
- "'Restart=always' in service_template.content | b64decode"
|
||||
- "'Requires=docker.service' in service_template.content | b64decode"
|
||||
- "'PartOf=docker.service' in service_template.content | b64decode"
|
||||
- "'StartLimitBurst=10' in service_template.content | b64decode"
|
||||
- "'DOCKER_HOST=unix:///run/user' in service_template.content | b64decode"
|
||||
- "'XDG_RUNTIME_DIR=/run/user' in service_template.content | b64decode"
|
||||
fail_msg: "User service template is missing expected directives"
|
||||
@@ -59,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
|
||||
|
||||
@@ -18,13 +18,11 @@
|
||||
else {} }}
|
||||
when: runner_file_stat.stat.exists | default(false) | bool
|
||||
|
||||
- name: Deregister runner with Gitea via CLI
|
||||
- name: Deregister runner from Gitea via API
|
||||
ansible.builtin.command: >
|
||||
{{ gitea_runner_binary_path }} delete
|
||||
--token {{ registration_token }}
|
||||
--name {{ runner_name }}
|
||||
--instance {{ gitea_url }}
|
||||
--no-interactive
|
||||
curl -sf --connect-timeout 5 --max-time 10 -X DELETE
|
||||
-H "Authorization: token {{ gitea_admin_token | default(registration_token) }}"
|
||||
"{{ gitea_url }}/api/v1/admin/actions/runners/{{ runner_reg.id }}"
|
||||
args:
|
||||
chdir: "{{ gitea_runner_data_dir }}"
|
||||
become: true
|
||||
@@ -35,10 +33,24 @@
|
||||
when:
|
||||
- runner_file_stat.stat.exists | default(false) | bool
|
||||
- not skip_runner_registration
|
||||
- runner_reg.id is defined
|
||||
register: deregister_output
|
||||
changed_when: deregister_output.rc == 0
|
||||
failed_when: false
|
||||
|
||||
- name: Warn if deregistration failed
|
||||
ansible.builtin.debug:
|
||||
msg: >-
|
||||
WARNING: Runner deregistration from Gitea failed (rc={{ deregister_output.rc | default('N/A') }}).
|
||||
The runner entry may remain in Gitea's admin UI as offline.
|
||||
Use an admin token (gitea_admin_token var) to enable automatic cleanup,
|
||||
or remove it manually from {{ gitea_url }}/-/admin/actions/runners
|
||||
when:
|
||||
- runner_file_stat.stat.exists | default(false) | bool
|
||||
- not skip_runner_registration
|
||||
- deregister_output is defined
|
||||
- deregister_output.rc | default(1) != 0
|
||||
|
||||
- name: Remove runner registration file
|
||||
ansible.builtin.file:
|
||||
path: "{{ gitea_runner_data_dir }}/.runner"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -6,4 +6,4 @@ Type=oneshot
|
||||
Environment=DOCKER_HOST=unix:///run/user/{{ gitea_runner_uid }}/docker.sock
|
||||
Environment=XDG_RUNTIME_DIR=/run/user/{{ gitea_runner_uid }}
|
||||
ExecStart=/usr/bin/docker system prune -f --filter "label={{ gitea_runner_prune_label }}" --filter "until={{ gitea_runner_prune_until }}"
|
||||
ExecStart=/usr/bin/docker volume prune -f --filter "label={{ gitea_runner_prune_label }}" --filter "until={{ gitea_runner_prune_until }}"
|
||||
ExecStart=/usr/bin/docker volume prune -f --filter "label={{ gitea_runner_prune_label }}"
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
[Unit]
|
||||
Description=Gitea Actions Runner (rootless)
|
||||
After=docker.service
|
||||
Requires=docker.service
|
||||
PartOf=docker.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
@@ -10,8 +12,10 @@ Environment=DOCKER_HOST=unix:///run/user/{{ gitea_runner_uid }}/docker.sock
|
||||
Environment=XDG_RUNTIME_DIR=/run/user/{{ gitea_runner_uid }}
|
||||
ExecStop=/bin/kill -TERM $MAINPID
|
||||
TimeoutStopSec=30
|
||||
Restart=on-failure
|
||||
Restart=always
|
||||
RestartSec={{ gitea_runner_service_restart_sec }}
|
||||
StartLimitIntervalSec=300
|
||||
StartLimitBurst=10
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
|
||||
@@ -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
|
||||
+13
-13
@@ -8,12 +8,12 @@ Each runner runs in an isolated **rootless Docker** environment under a dedicate
|
||||
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/src/branch/master/LICENSE)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/wiki)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/releases)
|
||||
[](https://www.python.org/downloads/)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/wiki)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/releases)
|
||||
[](https://www.python.org/downloads/)
|
||||
|
||||
## Overview
|
||||
|
||||
@@ -42,20 +42,20 @@ All supported OSes are tested in CI via Molecule scenarios on every PR that chan
|
||||
|
||||
## User Documentation
|
||||
|
||||
- [Getting Started](Getting-Started.-) — Installation, quick start, token setup, first run, log viewing
|
||||
- [Getting Started](Getting-Started) — Installation, quick start, token setup, first run, log viewing
|
||||
- [Installation](Installation) — Prerequisites, setup methods, multiple instances, runner registry
|
||||
- [CLI Commands](CLI-Commands.-) — All commands with arguments, options, and examples
|
||||
- [CLI Commands](CLI-Commands) — All commands with arguments, options, and examples
|
||||
- [Troubleshooting](Troubleshooting) — Common issues, diagnostics, and solutions
|
||||
- [FAQ](FAQ) — Frequently asked questions
|
||||
|
||||
## Technical Documentation
|
||||
|
||||
- [Architecture](Architecture) — High-level design, component diagram, data flow, security model, per-runner isolation
|
||||
- [Development Setup](Development-Setup.-) — Environment setup, project structure, dependencies, linting, testing
|
||||
- [CI/CD Workflow](CI-CD-Workflow.-) — PR workflow, branch protection, release pipeline, change classification, badge generation
|
||||
- [Testing Strategy](Testing-Strategy.-) — Unit tests, Molecule scenarios, integration tests, CI distribution
|
||||
- [Decision Log](Decision-Log.-) — Key technical decisions and rationale (ADRs)
|
||||
- [Contributing Guide](Contributing-Guide.-) — Coding standards, PR workflow, commit conventions, Ansible role conventions
|
||||
- [Development Setup](Development-Setup) — Environment setup, project structure, dependencies, linting, testing
|
||||
- [CI/CD Workflow](CI-CD-Workflow) — PR workflow, branch protection, release pipeline, change classification, badge generation
|
||||
- [Testing Strategy](Testing-Strategy) — Unit tests, Molecule scenarios, integration tests, CI distribution
|
||||
- [Decision Log](Decision-Log) — Key technical decisions and rationale (ADRs)
|
||||
- [Contributing Guide](Contributing-Guide) — Coding standards, PR workflow, commit conventions, Ansible role conventions
|
||||
|
||||
## Quick Links
|
||||
|
||||
|
||||
@@ -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`
|
||||
@@ -210,12 +216,10 @@ The Python CLI layer (`src/gitea_runner_manager/`) consists of the following mod
|
||||
| `executor.py` | Ansible subprocess execution — runs `ansible-playbook` with extra-vars via temp JSON files, streams output to log files |
|
||||
| `registry.py` | Local JSON runner registry at `~/.local/share/grm/runners.json` — stores connection metadata |
|
||||
| `i18n.py` | Internationalisation translations (en, bg, de, ru, zh, pl) — opt-in via `GRM_LANG` environment variable |
|
||||
| `exceptions.py` | Custom exceptions (`GRMError`, `AnsibleError`, `APIError`) |
|
||||
| `config.py` | Configuration constants (API URLs, repo owner/name, project IDs) — overridable via environment variables |
|
||||
| `exceptions.py` | Custom exceptions (`GRMError`, `AnsibleError`) |
|
||||
| `logging_config.py` | Logging configuration — writes all messages to `~/.local/state/grm/logs/grm.log` at DEBUG level |
|
||||
| `report.py` | Operation report tracking — prints a step-by-step report with status icons after each command |
|
||||
| `ui.py` | User-facing output utilities — colorised console output via `click.style`, with log file always receiving plain text |
|
||||
| `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
|
||||
|
||||
@@ -155,7 +155,7 @@ Once all comments are addressed, post an approval review:
|
||||
CI_GITEA_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."
|
||||
--body "All 13 checklist categories verified."
|
||||
```
|
||||
|
||||
Then add the `ready-to-merge` label. The auto-merge workflow will:
|
||||
@@ -216,7 +216,7 @@ Not all changes require a new release. The project classifies changes using `dev
|
||||
- Lint config files, `.env.example`, `.gitignore`
|
||||
|
||||
**User-facing paths** (release needed):
|
||||
- `src/gitea_runner_manager/**` (except `__init__.py` and `api_clients.py`)
|
||||
- `src/gitea_runner_manager/**` (except `__init__.py`)
|
||||
- `ansible/**`
|
||||
- `pyproject.toml`
|
||||
|
||||
|
||||
@@ -94,7 +94,7 @@ Key technical decisions for the GRM project, extracted from `CHANGELOG.md` and `
|
||||
|
||||
**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.
|
||||
**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 `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]`)
|
||||
|
||||
|
||||
@@ -11,11 +11,9 @@
|
||||
│ ├── registry.py # Local JSON runner registry
|
||||
│ ├── i18n.py # Translations (en, bg, de, ru, zh, pl)
|
||||
│ ├── 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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -299,5 +389,5 @@ All CLI options can be set via environment variables (loaded from `.env` via pyt
|
||||
| `GITEA_RUNNER_USER` | `install`, `update` | Default SSH user |
|
||||
| `GITEA_RUNNER_KEY` | `install`, `update` | Default SSH key path |
|
||||
| `GITEA_RUNNER_LABELS` | `install` | Default runner labels |
|
||||
| `GRM_LANG` | all | UI language: `en`, `bg`, `de`, `ru`, `zh` |
|
||||
| `GRM_LANG` | all | UI language: `en`, `bg`, `de`, `ru`, `zh`, `pl` |
|
||||
| `GRM_LOG_LEVEL` | all | Console log level: `DEBUG`, `INFO`, `WARNING`, `ERROR`, `CRITICAL` |
|
||||
|
||||
+18
-18
@@ -1,6 +1,6 @@
|
||||
# FAQ
|
||||
|
||||
### How do I obtain the Gitea registration token?
|
||||
## How do I obtain the Gitea registration token?
|
||||
|
||||
There are three levels of registration tokens, depending on which repositories the runner should serve:
|
||||
|
||||
@@ -10,7 +10,7 @@ There are three levels of registration tokens, depending on which repositories t
|
||||
|
||||
Set the token as `GITEA_REGISTRATION_TOKEN` in your `.env` file or pass it via `--token` on the command line.
|
||||
|
||||
### What is the CI_GITEA_TOKEN and do I need it?
|
||||
## What is the CI_GITEA_TOKEN and do I need it?
|
||||
|
||||
`CI_GITEA_TOKEN` is a Gitea admin API token used for optional post-install verification. When set, GRM queries the Gitea API after installation to confirm the runner appears in the runner list. This is purely informational — the integration test passes/fails based on the `.runner` file and systemd service, not the API check.
|
||||
|
||||
@@ -18,7 +18,7 @@ To generate one: Settings → Applications → Generate New Token, with the `adm
|
||||
|
||||
If you skip it, GRM will still verify the runner correctly — it just won't show the extra API confirmation.
|
||||
|
||||
### How do I skip the sudo password prompt for automation?
|
||||
## How do I skip the sudo password prompt for automation?
|
||||
|
||||
Configure passwordless sudo on the remote host and pass `--no-ask-become-pass` to the CLI command. This is recommended for CI/CD pipelines.
|
||||
|
||||
@@ -34,7 +34,7 @@ Then use:
|
||||
grm install 192.168.1.10 --user ubuntu --key ~/.ssh/id_ed25519 --name prod-runner --no-ask-become-pass
|
||||
```
|
||||
|
||||
### Can I run multiple runners on the same host?
|
||||
## Can I run multiple runners on the same host?
|
||||
|
||||
Yes. Each runner instance is fully isolated with its own system user (`grm-<name>`), rootless Docker daemon, data directory, and systemd user service. Install additional runners with different `--name` values and manage them independently by name.
|
||||
|
||||
@@ -46,7 +46,7 @@ grm list
|
||||
|
||||
Runners on the same host never interfere with each other or with the host's Docker installation.
|
||||
|
||||
### Why does my runner appear offline after installation?
|
||||
## Why does my runner appear offline after installation?
|
||||
|
||||
Check that `GITEA_URL` and `GITEA_REGISTRATION_TOKEN` are correct, verify the runner service is running with `sudo -u grm-<name> systemctl --user status gitea-runner`, and check the logs for registration errors. You can also confirm the runner appears as **Online** in the Gitea UI under **Actions → Runners**.
|
||||
|
||||
@@ -56,15 +56,15 @@ Common causes:
|
||||
- Rootless Docker daemon not running — check `sudo -u grm-<name> systemctl --user status docker`
|
||||
- Lingering not enabled — check `loginctl show-user grm-<name> | grep Linger`
|
||||
|
||||
### What does the "Event loop is closed" warning mean?
|
||||
## What does the "Event loop is closed" warning mean?
|
||||
|
||||
This is a harmless cleanup traceback from Molecule's Docker driver when the test process is interrupted. It does not indicate a test failure.
|
||||
|
||||
### Where are runner connection details stored?
|
||||
## Where are runner connection details stored?
|
||||
|
||||
GRM stores each runner's connection details (host, user, SSH key, Gitea URL, labels) in a local JSON registry at `~/.local/share/grm/runners.json`. After installation, lifecycle commands work by runner name only — you can override any stored value by passing the corresponding flag.
|
||||
|
||||
### How do I update the gitea_runner binary?
|
||||
## How do I update the gitea_runner binary?
|
||||
|
||||
Use the `grm update` command:
|
||||
|
||||
@@ -80,7 +80,7 @@ grm update 192.168.1.10 --user ubuntu --version 1.0.8
|
||||
|
||||
The update command downloads the new binary and replaces the existing one at `/usr/local/bin/gitea_runner`. The runner service is restarted automatically.
|
||||
|
||||
### How do I completely remove a runner?
|
||||
## How do I completely remove a runner?
|
||||
|
||||
Use the `grm remove` command:
|
||||
|
||||
@@ -96,18 +96,18 @@ If the remote host is already gone or unreachable, use `--force` to skip remote
|
||||
grm remove prod-runner --force
|
||||
```
|
||||
|
||||
### What is the difference between disable and remove?
|
||||
## What is the difference between disable and remove?
|
||||
|
||||
- **`grm disable <name>`** — Deregisters the runner from Gitea and stops the service, but leaves the user, directories, and service files in place. The runner can be re-enabled later with `grm enable` and re-registered with a new token.
|
||||
- **`grm remove <name>`** — Completely removes the runner: deregisters from Gitea, stops and disables the service, removes the system user, deletes all directories, and removes the local registry entry. This is irreversible.
|
||||
|
||||
### What operating systems are supported?
|
||||
## What operating systems are supported?
|
||||
|
||||
GRM supports Arch Linux (rolling), Ubuntu 22.04/24.04, and Debian 12. All supported OSes are tested in CI via Molecule scenarios on every PR that changes Ansible files.
|
||||
|
||||
### How do I change the UI language?
|
||||
## How do I change the UI language?
|
||||
|
||||
Set the `GRM_LANG` environment variable to one of the supported languages: `en` (English, default), `bg` (Bulgarian), `de` (German), `ru` (Russian), `zh` (Chinese).
|
||||
Set the `GRM_LANG` environment variable to one of the supported languages: `en` (English, default), `bg` (Bulgarian), `de` (German), `ru` (Russian), `zh` (Chinese), `pl` (Polish).
|
||||
|
||||
```bash
|
||||
GRM_LANG=bg grm install 192.168.1.10 --user ubuntu --name prod-runner
|
||||
@@ -119,7 +119,7 @@ Or set it in your `.env` file:
|
||||
GRM_LANG=bg
|
||||
```
|
||||
|
||||
### How do I enable debug logging?
|
||||
## How do I enable debug logging?
|
||||
|
||||
Set the `GRM_LOG_LEVEL` environment variable to `DEBUG`:
|
||||
|
||||
@@ -129,7 +129,7 @@ GRM_LOG_LEVEL=DEBUG grm install 192.168.1.10 --user ubuntu --name prod-runner
|
||||
|
||||
The log file at `~/.local/state/grm/logs/grm.log` always captures DEBUG level regardless of this setting. Ansible execution logs are stored in timestamped files at `~/.local/state/grm/logs/ansible-<timestamp>.log`.
|
||||
|
||||
### What runner labels should I use?
|
||||
## What runner labels should I use?
|
||||
|
||||
By default, runners are registered with `docker,ubuntu-latest:docker://runner-images:ubuntu-22.04`. You can override this with `--labels` or the `GITEA_RUNNER_LABELS` environment variable.
|
||||
|
||||
@@ -142,7 +142,7 @@ grm install 192.168.1.10 --user ubuntu --name prod-runner \
|
||||
--labels "docker:docker://gitea/runner-images:ubuntu-latest"
|
||||
```
|
||||
|
||||
### Is GRM secure?
|
||||
## Is GRM secure?
|
||||
|
||||
Yes. GRM is designed with security as a first-class concern:
|
||||
|
||||
@@ -151,7 +151,7 @@ Yes. GRM is designed with security as a first-class concern:
|
||||
- **No shell injection**: The CLI never uses `shell=True` with subprocess.
|
||||
- **Bandit security scan**: The CI pipeline runs Bandit on every PR.
|
||||
|
||||
### Can I install GRM via pip?
|
||||
## Can I install GRM via pip?
|
||||
|
||||
Yes:
|
||||
|
||||
@@ -161,7 +161,7 @@ pip install gitea-runner-manager
|
||||
|
||||
This installs the `grm` CLI and its Python dependencies. The Ansible playbooks and role are bundled with the package. For development or access to Make targets, clone the repository instead.
|
||||
|
||||
### How does GRM handle idempotence?
|
||||
## How does GRM handle idempotence?
|
||||
|
||||
The Ansible role is idempotent — running `grm install` twice produces zero changes on the second run. Each task checks for existing state before making changes. For example:
|
||||
|
||||
|
||||
@@ -98,7 +98,7 @@ CI_GITEA_TOKEN=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||
| `GITEA_RUNNER_USER` | No | current login | Default SSH user (overrides `--user`) |
|
||||
| `GITEA_RUNNER_KEY` | No | — | Default SSH key path (overrides `--key`) |
|
||||
| `GITEA_RUNNER_LABELS` | No | — | Default runner labels (overrides `--labels`) |
|
||||
| `GRM_LANG` | No | `en` | UI language: `en`, `bg`, `de`, `ru`, `zh` |
|
||||
| `GRM_LANG` | No | `en` | UI language: `en`, `bg`, `de`, `ru`, `zh`, `pl` |
|
||||
| `GRM_LOG_LEVEL` | No | `INFO` | Console log level: `DEBUG`, `INFO`, `WARNING`, `ERROR`, `CRITICAL` |
|
||||
|
||||
## Step 3: Install Your First Runner
|
||||
@@ -182,7 +182,7 @@ grm disable prod-runner # Disable and deregister the runner
|
||||
grm remove prod-runner # Remove the runner completely
|
||||
```
|
||||
|
||||
See [CLI Commands](CLI-Commands.-) for the full command reference.
|
||||
See [CLI Commands](CLI-Commands) for the full command reference.
|
||||
|
||||
## View Logs
|
||||
|
||||
@@ -232,6 +232,6 @@ Console output is automatically colorised via `click.style`: operation headers i
|
||||
## Next Steps
|
||||
|
||||
- **Install more runners** on the same or different hosts — see [Installation](Installation)
|
||||
- **Learn all CLI commands** — see [CLI Commands](CLI-Commands.-)
|
||||
- **Learn all CLI commands** — see [CLI Commands](CLI-Commands)
|
||||
- **Troubleshoot issues** — see [Troubleshooting](Troubleshooting)
|
||||
- **Understand the architecture** — see [Architecture](Architecture)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Installation
|
||||
|
||||
> **Before you start:** Make sure you have cloned the repo and checked out the latest stable release tag. See [Getting Started](Getting-Started.-) for setup instructions. Do not run from `master` — it may contain unreleased changes.
|
||||
> **Before you start:** Make sure you have cloned the repo and checked out the latest stable release tag. See [Getting Started](Getting-Started) for setup instructions. Do not run from `master` — it may contain unreleased changes.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
|
||||
- **SSH server** — The remote host must be reachable via SSH using the user specified with `--user` and the private key specified with `--key`. GRM uses Ansible under the hood, which connects to the target host over SSH to execute all installation and configuration tasks. Without valid SSH credentials, Ansible cannot establish a connection and the deployment will fail.
|
||||
- **Sudo access** — GRM requires root privileges on the remote host to create system users, install packages, and configure rootless Docker. By default, you will be prompted interactively for the sudo password. For automation or uninterrupted workflows, configure passwordless sudo on the remote host and pass `--no-ask-become-pass`.
|
||||
- **Gitea registration token** — You need a runner registration token from your Gitea instance. See [Getting Started](Getting-Started.-) for detailed instructions on obtaining tokens.
|
||||
- **Gitea registration token** — You need a runner registration token from your Gitea instance. See [Getting Started](Getting-Started) for detailed instructions on obtaining tokens.
|
||||
- **systemd** — Required for user services and lingering. All supported OSes ship with systemd.
|
||||
- **Docker** — Installed automatically by the Ansible role (rootless mode). No pre-existing Docker installation is required.
|
||||
|
||||
@@ -96,7 +96,7 @@ Required variables:
|
||||
| `GITEA_URL` | Your Gitea instance URL (e.g., `https://git.example.com`) |
|
||||
| `GITEA_REGISTRATION_TOKEN` | Runner registration token from Gitea (starts with `GR`) |
|
||||
|
||||
See [Getting Started](Getting-Started.-) for detailed token setup instructions.
|
||||
See [Getting Started](Getting-Started) for detailed token setup instructions.
|
||||
|
||||
## Quick Start Install
|
||||
|
||||
|
||||
+18
-22
@@ -14,10 +14,9 @@ classifiers = [
|
||||
"License :: OSI Approved :: GNU General Public License v3 (GPLv3)",
|
||||
]
|
||||
dependencies = [
|
||||
"requests>=2.34.2",
|
||||
"python-dotenv>=1.2.2",
|
||||
"click>=8.4.1",
|
||||
"ansible>=14.0.0",
|
||||
"python-dotenv==1.2.2",
|
||||
"click==8.4.2",
|
||||
"ansible==14.1.0",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
@@ -27,35 +26,35 @@ grm = "gitea_runner_manager.cli:cli"
|
||||
version = {attr = "gitea_runner_manager.__version__"}
|
||||
|
||||
[project.optional-dependencies]
|
||||
# Minimal deps for CI scripts that only need click/dotenv/requests
|
||||
# Minimal deps for CI scripts that only need click/dotenv
|
||||
# (detect-changes, discover-runners, pr-review, sync-wiki, badges, etc.)
|
||||
ci = [
|
||||
"pytest>=9.1.0",
|
||||
"pytest-cov>=7.1.0",
|
||||
"build>=1.5.0",
|
||||
"twine>=6.2.0",
|
||||
"pytest==9.1.1",
|
||||
"pytest-cov==7.1.0",
|
||||
"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.0",
|
||||
"devx==0.33.1",
|
||||
]
|
||||
# Lint and type-checking tools (quality job)
|
||||
lint = [
|
||||
"ruff>=0.15.17",
|
||||
"pyright>=1.1.410",
|
||||
"bandit>=1.8.2",
|
||||
"pip-audit>=2.10",
|
||||
"pre-commit>=4.6.0",
|
||||
"ansible-lint>=26.4.0",
|
||||
"ruff==0.15.20",
|
||||
"pyright==1.1.411",
|
||||
"bandit==1.9.4",
|
||||
"pip-audit==2.10.1",
|
||||
"pre-commit==4.6.0",
|
||||
"ansible-lint==26.4.0",
|
||||
]
|
||||
# Molecule testing (molecule-tests job)
|
||||
molecule = [
|
||||
"molecule>=26.4.0",
|
||||
"molecule-docker>=2.1.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.0",
|
||||
"devx==0.33.1",
|
||||
# Non-Python dev dependency: checkmake (Makefile linter)
|
||||
# Install via: go install github.com/checkmake/checkmake/cmd/checkmake@latest
|
||||
]
|
||||
@@ -128,11 +127,8 @@ infrastructure = ["scripts/**"]
|
||||
# Infrastructure overrides — files that would default to user-facing
|
||||
# but are actually infrastructure:
|
||||
# - __init__.py: only contains __version__ (set by release.py, not user code)
|
||||
# - api_clients.py: used only by tests and legacy CI scripts (now in devx),
|
||||
# not by the grm CLI tool itself
|
||||
infrastructure_overrides = [
|
||||
"src/gitea_runner_manager/__init__.py",
|
||||
"src/gitea_runner_manager/api_clients.py",
|
||||
]
|
||||
|
||||
# User-facing overrides — safety override for broad infrastructure patterns
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""Gitea Runner Manager — lean CLI for managing Gitea Actions runners."""
|
||||
|
||||
__version__ = "0.11.0"
|
||||
__version__ = "0.14.2"
|
||||
|
||||
@@ -1,353 +0,0 @@
|
||||
"""Reusable HTTP API clients for Gitea and Vikunja."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
import requests
|
||||
|
||||
from .config import DEFAULT_TIMEOUT
|
||||
from .exceptions import APIError
|
||||
|
||||
logger = logging.getLogger("grm")
|
||||
|
||||
# Retry configuration for transient errors (429, 5xx, connection errors)
|
||||
MAX_RETRIES = 3
|
||||
RETRY_BACKOFF_BASE = 2 # seconds: 2, 4, 8
|
||||
RETRY_STATUS_CODES = {429, 500, 502, 503, 504}
|
||||
|
||||
|
||||
def _parse_error(e: requests.HTTPError) -> tuple[int, str]:
|
||||
"""Extract status code and message from an HTTPError response."""
|
||||
response = getattr(e, "response", None)
|
||||
status = response.status_code if response is not None else 0
|
||||
try:
|
||||
body: dict[str, Any] = response.json() if response is not None else {}
|
||||
message: str = body.get("message", str(e))
|
||||
except Exception:
|
||||
message = str(e)
|
||||
return status, message
|
||||
|
||||
|
||||
def _is_retryable(e: Exception) -> bool:
|
||||
"""Check if an exception is a transient error worth retrying."""
|
||||
if isinstance(e, requests.ConnectionError):
|
||||
return True
|
||||
if isinstance(e, requests.HTTPError):
|
||||
status, _ = _parse_error(e)
|
||||
return status in RETRY_STATUS_CODES
|
||||
return isinstance(e, requests.Timeout)
|
||||
|
||||
|
||||
class GiteaClient:
|
||||
"""Low-level Gitea REST API client with connection pooling."""
|
||||
|
||||
def __init__(self, base_url: str, token: str, owner: str, repo: str) -> None:
|
||||
self._base_url = base_url.rstrip("/")
|
||||
self._owner = owner
|
||||
self._repo = repo
|
||||
self._session = requests.Session()
|
||||
self._session.headers.update(
|
||||
{
|
||||
"Authorization": f"token {token}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
)
|
||||
|
||||
def _url(self, path: str) -> str:
|
||||
return f"{self._base_url}/repos/{self._owner}/{self._repo}{path}"
|
||||
|
||||
def _request(self, method: str, path: str, **kwargs: Any) -> requests.Response:
|
||||
url = self._url(path)
|
||||
last_exc: Exception | None = None
|
||||
for attempt in range(MAX_RETRIES):
|
||||
try:
|
||||
response = self._session.request(method, url, timeout=DEFAULT_TIMEOUT, **kwargs)
|
||||
response.raise_for_status()
|
||||
return response
|
||||
except requests.HTTPError as e:
|
||||
status, message = _parse_error(e)
|
||||
if _is_retryable(e) and attempt < MAX_RETRIES - 1:
|
||||
wait = RETRY_BACKOFF_BASE ** (attempt + 1)
|
||||
logger.warning(
|
||||
"Transient HTTP %d on %s %s, retrying in %ds (attempt %d/%d)",
|
||||
status,
|
||||
method,
|
||||
path,
|
||||
wait,
|
||||
attempt + 1,
|
||||
MAX_RETRIES,
|
||||
)
|
||||
time.sleep(wait)
|
||||
last_exc = e
|
||||
continue
|
||||
raise APIError(status, message) from e
|
||||
except (requests.ConnectionError, requests.Timeout) as e:
|
||||
if attempt < MAX_RETRIES - 1:
|
||||
wait = RETRY_BACKOFF_BASE ** (attempt + 1)
|
||||
logger.warning(
|
||||
"Connection error on %s %s, retrying in %ds (attempt %d/%d)",
|
||||
method,
|
||||
path,
|
||||
wait,
|
||||
attempt + 1,
|
||||
MAX_RETRIES,
|
||||
)
|
||||
time.sleep(wait)
|
||||
last_exc = e
|
||||
continue
|
||||
raise APIError(0, str(e)) from e
|
||||
# Should not reach here, but just in case
|
||||
if last_exc: # pragma: no cover
|
||||
raise APIError(0, str(last_exc)) from last_exc
|
||||
raise APIError(0, "Max retries exceeded") # pragma: no cover
|
||||
|
||||
# -- repo settings --
|
||||
|
||||
def update_repo_settings(self, settings: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Update repository settings (e.g. auto-delete branch after merge)."""
|
||||
r = self._request("PATCH", "", json=settings)
|
||||
return r.json()
|
||||
|
||||
# -- branch protection --
|
||||
|
||||
def list_branch_protections(self) -> list[dict[str, Any]]:
|
||||
r = self._request("GET", "/branch_protections")
|
||||
return r.json()
|
||||
|
||||
def create_branch_protection(self, config: dict[str, Any]) -> dict[str, Any]:
|
||||
r = self._request("POST", "/branch_protections", json=config)
|
||||
return r.json()
|
||||
|
||||
def update_branch_protection(self, branch: str, config: dict[str, Any]) -> dict[str, Any]:
|
||||
r = self._request("PATCH", f"/branch_protections/{branch}", json=config)
|
||||
return r.json()
|
||||
|
||||
def ensure_branch_protection(self, branch: str, config: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Idempotent: create or update branch protection for the given branch."""
|
||||
existing = self.list_branch_protections()
|
||||
for p in existing:
|
||||
if p.get("branch_name") == branch:
|
||||
update_config = {k: v for k, v in config.items() if k != "branch_name"}
|
||||
return self.update_branch_protection(branch, update_config)
|
||||
return self.create_branch_protection(config)
|
||||
|
||||
# -- labels --
|
||||
|
||||
def list_labels(self) -> list[dict[str, Any]]:
|
||||
r = self._request("GET", "/labels")
|
||||
return r.json()
|
||||
|
||||
def create_label(self, name: str, color: str, description: str = "") -> dict[str, Any]:
|
||||
r = self._request(
|
||||
"POST",
|
||||
"/labels",
|
||||
json={"name": name, "color": color, "description": description},
|
||||
)
|
||||
return r.json()
|
||||
|
||||
def ensure_label(self, name: str, color: str, description: str = "") -> dict[str, Any] | None:
|
||||
"""Idempotent: create label if it doesn't already exist."""
|
||||
labels = self.list_labels()
|
||||
for label in labels:
|
||||
if label["name"] == name:
|
||||
return None
|
||||
return self.create_label(name, color, description)
|
||||
|
||||
def create_issue(self, title: str, body: str = "", labels: list[int] | None = None) -> dict[str, Any]:
|
||||
"""Create a new issue in the repository.
|
||||
|
||||
Args:
|
||||
labels: List of label IDs (integers, not names).
|
||||
"""
|
||||
payload: dict[str, Any] = {"title": title, "body": body}
|
||||
if labels:
|
||||
payload["labels"] = labels
|
||||
r = self._request("POST", "/issues", json=payload)
|
||||
return r.json()
|
||||
|
||||
# -- pulls / releases --
|
||||
|
||||
def get_pr_labels(self, pr_number: str | int) -> list[dict[str, Any]]:
|
||||
"""Fetch labels currently attached to a pull request."""
|
||||
r = self._request("GET", f"/issues/{pr_number}/labels")
|
||||
return r.json()
|
||||
|
||||
def merge_pr(self, pr_number: str | int, merge_title: str) -> None:
|
||||
payload = {"Do": "squash", "MergeTitleField": merge_title}
|
||||
self._request("POST", f"/pulls/{pr_number}/merge", json=payload)
|
||||
|
||||
def get_commit_status(self, sha: str) -> list[dict[str, Any]]:
|
||||
"""Fetch all status check contexts reported for a commit.
|
||||
|
||||
Uses the combined status endpoint (/commits/{sha}/status) which
|
||||
returns one entry per context (the latest), deduplicated server-side.
|
||||
The plural endpoint (/commits/{sha}/statuses) returns every historical
|
||||
entry including stale "pending" ones that never got updated.
|
||||
"""
|
||||
r = self._request("GET", f"/commits/{sha}/status")
|
||||
data = r.json()
|
||||
return data.get("statuses", [])
|
||||
|
||||
def get_pr(self, pr_number: str | int) -> dict[str, Any]:
|
||||
"""Fetch pull request details including mergeable state."""
|
||||
r = self._request("GET", f"/pulls/{pr_number}")
|
||||
return r.json()
|
||||
|
||||
def get_pr_files(self, pr_number: str | int) -> list[dict[str, Any]]:
|
||||
"""Fetch the list of files changed in a pull request."""
|
||||
r = self._request("GET", f"/pulls/{pr_number}/files")
|
||||
return r.json()
|
||||
|
||||
def get_pr_commits(self, pr_number: str | int) -> list[dict[str, Any]]:
|
||||
"""Fetch the commits included in a pull request."""
|
||||
r = self._request("GET", f"/pulls/{pr_number}/commits")
|
||||
return r.json()
|
||||
|
||||
def get_pr_reviews(self, pr_number: str | int) -> list[dict[str, Any]]:
|
||||
"""Fetch reviews posted on a pull request."""
|
||||
r = self._request("GET", f"/pulls/{pr_number}/reviews")
|
||||
return r.json()
|
||||
|
||||
def create_review(
|
||||
self,
|
||||
pr_number: str | int,
|
||||
event: str = "COMMENT",
|
||||
body: str = "",
|
||||
comments: list[dict[str, Any]] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Post a review on a pull request.
|
||||
|
||||
Args:
|
||||
event: ``APPROVED``, ``REQUEST_CHANGES``, or ``COMMENT``.
|
||||
body: Top-level review body text.
|
||||
comments: Line-level comments with ``path``, ``body``,
|
||||
``new_position`` (and optionally ``old_position``).
|
||||
"""
|
||||
# Map common event names to Gitea API values
|
||||
event_map = {"APPROVE": "APPROVED", "REQUEST_CHANGES": "REQUEST_CHANGES", "COMMENT": "COMMENT"}
|
||||
gitea_event = event_map.get(event, event)
|
||||
payload: dict[str, Any] = {"event": gitea_event, "body": body}
|
||||
if comments:
|
||||
payload["comments"] = comments
|
||||
r = self._request("POST", f"/pulls/{pr_number}/reviews", json=payload)
|
||||
return r.json()
|
||||
|
||||
def create_release(
|
||||
self,
|
||||
tag: str,
|
||||
name: str = "",
|
||||
body: str = "",
|
||||
draft: bool = False,
|
||||
prerelease: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
payload = {
|
||||
"tag_name": tag,
|
||||
"name": name or tag,
|
||||
"body": body,
|
||||
"draft": draft,
|
||||
"prerelease": prerelease,
|
||||
}
|
||||
r = self._request("POST", "/releases", json=payload)
|
||||
return r.json()
|
||||
|
||||
def get_release_by_tag(self, tag: str) -> dict[str, Any] | None:
|
||||
"""Fetch a release by its tag name. Returns None if not found."""
|
||||
try:
|
||||
r = self._request("GET", f"/releases/tags/{tag}")
|
||||
return r.json()
|
||||
except APIError:
|
||||
return None
|
||||
|
||||
def create_release_idempotent(
|
||||
self,
|
||||
tag: str,
|
||||
name: str = "",
|
||||
body: str = "",
|
||||
draft: bool = False,
|
||||
prerelease: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Create a release, or return the existing one if it already exists.
|
||||
|
||||
This is idempotent — safe to call multiple times for the same tag.
|
||||
"""
|
||||
existing = self.get_release_by_tag(tag)
|
||||
if existing:
|
||||
logger.info("Release for tag %s already exists (ID %s), skipping creation.", tag, existing.get("id"))
|
||||
return existing
|
||||
return self.create_release(tag=tag, name=name, body=body, draft=draft, prerelease=prerelease)
|
||||
|
||||
|
||||
class VikunjaClient:
|
||||
"""Low-level Vikunja REST API client with connection pooling."""
|
||||
|
||||
def __init__(self, base_url: str, token: str) -> None:
|
||||
self._base_url = base_url.rstrip("/")
|
||||
self._session = requests.Session()
|
||||
self._session.headers.update({"Authorization": f"Bearer {token}"})
|
||||
|
||||
def _request(self, method: str, path: str, **kwargs: Any) -> requests.Response:
|
||||
url = f"{self._base_url}{path}"
|
||||
last_exc: Exception | None = None
|
||||
for attempt in range(MAX_RETRIES):
|
||||
try:
|
||||
response = self._session.request(method, url, timeout=DEFAULT_TIMEOUT, **kwargs)
|
||||
response.raise_for_status()
|
||||
return response
|
||||
except requests.HTTPError as e:
|
||||
status, message = _parse_error(e)
|
||||
if _is_retryable(e) and attempt < MAX_RETRIES - 1:
|
||||
wait = RETRY_BACKOFF_BASE ** (attempt + 1)
|
||||
logger.warning(
|
||||
"Transient HTTP %d on %s %s, retrying in %ds (attempt %d/%d)",
|
||||
status,
|
||||
method,
|
||||
path,
|
||||
wait,
|
||||
attempt + 1,
|
||||
MAX_RETRIES,
|
||||
)
|
||||
time.sleep(wait)
|
||||
last_exc = e
|
||||
continue
|
||||
raise APIError(status, message) from e
|
||||
except (requests.ConnectionError, requests.Timeout) as e:
|
||||
if attempt < MAX_RETRIES - 1:
|
||||
wait = RETRY_BACKOFF_BASE ** (attempt + 1)
|
||||
logger.warning(
|
||||
"Connection error on %s %s, retrying in %ds (attempt %d/%d)",
|
||||
method,
|
||||
path,
|
||||
wait,
|
||||
attempt + 1,
|
||||
MAX_RETRIES,
|
||||
)
|
||||
time.sleep(wait)
|
||||
last_exc = e
|
||||
continue
|
||||
raise APIError(0, str(e)) from e
|
||||
if last_exc: # pragma: no cover
|
||||
raise APIError(0, str(last_exc)) from last_exc
|
||||
raise APIError(0, "Max retries exceeded") # pragma: no cover
|
||||
|
||||
def list_tasks(self, **params: Any) -> list[dict[str, Any]]:
|
||||
r = self._request("GET", "/tasks", params=params)
|
||||
return r.json()
|
||||
|
||||
def get_task(self, task_id: int) -> dict[str, Any]:
|
||||
"""Fetch a single task by its numeric ID."""
|
||||
r = self._request("GET", f"/tasks/{task_id}")
|
||||
return r.json()
|
||||
|
||||
def list_project_tasks(self, project_id: int, **params: Any) -> list[dict[str, Any]]:
|
||||
"""List tasks in a specific project (more efficient than listing all tasks)."""
|
||||
r = self._request("GET", f"/projects/{project_id}/tasks", params=params)
|
||||
return r.json()
|
||||
|
||||
def post_comment(self, task_id: int, comment: str) -> None:
|
||||
self._request("PUT", f"/tasks/{task_id}/comments", json={"comment": comment})
|
||||
|
||||
def update_task(self, task_id: int, **fields: Any) -> None:
|
||||
self._request("POST", f"/tasks/{task_id}", json=fields)
|
||||
@@ -14,6 +14,7 @@ from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnk
|
||||
|
||||
from . import __version__
|
||||
from .exceptions import GRMError
|
||||
from .gitea_client import GiteaAPIError, GiteaWorkflowClient
|
||||
from .i18n import _
|
||||
from .runner_manager import RunnerManager
|
||||
|
||||
@@ -441,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",
|
||||
@@ -468,3 +507,82 @@ def list_runners(ask_become_pass: bool, no_status: bool) -> None:
|
||||
click.echo("-" * 90)
|
||||
for r in runners:
|
||||
click.echo(f"{r['name']:<18} {r['host']:<16} {r['user']:<10} {r['labels']:<30} {r['status']}")
|
||||
|
||||
|
||||
@cli.command(name="trigger-workflow", help=_("Trigger a Gitea Actions workflow via the API."))
|
||||
@click.argument("workflow_id", required=False)
|
||||
@click.option(
|
||||
"--repo",
|
||||
default=lambda: os.getenv("GRM_REPO", "oblachno-oss/grm"),
|
||||
help=_("Repository in owner/repo format (env: GRM_REPO, default: oblachno-oss/grm)"),
|
||||
)
|
||||
@click.option(
|
||||
"--ref",
|
||||
default="master",
|
||||
help=_("Git ref to run the workflow on (default: master)"),
|
||||
)
|
||||
@click.option(
|
||||
"--url",
|
||||
default=lambda: os.getenv("GITEA_URL", ""),
|
||||
help=_("Gitea URL (env: GITEA_URL)"),
|
||||
)
|
||||
@click.option(
|
||||
"--token",
|
||||
default=lambda: os.getenv("CI_GITEA_TOKEN"),
|
||||
help=_("Gitea API token (env: CI_GITEA_TOKEN)"),
|
||||
)
|
||||
@click.option(
|
||||
"--list",
|
||||
"list_only",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help=_("List available workflows instead of triggering one"),
|
||||
)
|
||||
def trigger_workflow(
|
||||
workflow_id: str,
|
||||
repo: str,
|
||||
ref: str,
|
||||
url: str,
|
||||
token: str | None,
|
||||
list_only: bool,
|
||||
) -> None:
|
||||
"""Trigger a Gitea Actions workflow dispatch event."""
|
||||
if not url:
|
||||
raise click.ClickException(_("GITEA_URL is required (set --url or GITEA_URL env var)"))
|
||||
if not token:
|
||||
raise click.ClickException(_("CI_GITEA_TOKEN is required (set --token or CI_GITEA_TOKEN env var)"))
|
||||
if not list_only and not workflow_id:
|
||||
raise click.ClickException(_("WORKFLOW_ID is required unless --list is used"))
|
||||
|
||||
client = GiteaWorkflowClient(url, token)
|
||||
owner, repo_name = repo.split("/", 1)
|
||||
|
||||
if list_only:
|
||||
workflows = client.list_workflows(owner, repo_name)
|
||||
if not workflows:
|
||||
click.echo(_("No workflows found in {repo}", repo=repo))
|
||||
return
|
||||
click.echo(f"{'ID':<30} {'NAME':<20} {'PATH':<25} {'STATE'}")
|
||||
click.echo("-" * 85)
|
||||
for wf in workflows:
|
||||
wf_id = str(wf.get("id", ""))
|
||||
wf_name = wf.get("name", "")
|
||||
wf_path = wf.get("path", "")
|
||||
wf_state = wf.get("state", "")
|
||||
click.echo(f"{wf_id:<30} {wf_name:<20} {wf_path:<25} {wf_state}")
|
||||
return
|
||||
|
||||
click.echo(_("Triggering workflow {wf} on {repo}@{ref}...", wf=workflow_id, repo=repo, ref=ref))
|
||||
try:
|
||||
result = client.dispatch_workflow(owner, repo_name, workflow_id, ref)
|
||||
except GiteaAPIError as e:
|
||||
raise click.ClickException(str(e)) from e
|
||||
|
||||
if result and result.get("id"):
|
||||
run_id: Any = result["id"]
|
||||
click.echo(_("Workflow triggered successfully. Run ID: {run_id}", run_id=run_id))
|
||||
if result.get("html_url"):
|
||||
html_url: Any = result["html_url"]
|
||||
click.echo(f" {html_url}")
|
||||
else:
|
||||
click.echo(_("Workflow triggered successfully."))
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
"""Shared configuration constants for GRM scripts and API clients."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
|
||||
GITEA_API_URL = os.getenv("GRM_GITEA_API_URL", "https://git.oblachno.oblachno.fyi/api/v1")
|
||||
VIKUNJA_API_URL = os.getenv("GRM_VIKUNJA_API_URL", "https://work.oblachno.oblachno.fyi/api/v1")
|
||||
|
||||
REPO_OWNER = os.getenv("GRM_REPO_OWNER", "oblachno-oss")
|
||||
REPO_NAME = os.getenv("GRM_REPO_NAME", "grm")
|
||||
|
||||
VIKUNJA_PROJECT_ID = int(os.getenv("GRM_VIKUNJA_PROJECT_ID", "6"))
|
||||
|
||||
TASK_ID_RE = re.compile(r"GRM-\d+")
|
||||
CONVENTIONAL_RE = re.compile(r"^(feat|fix|chore|docs|style|refactor|perf|test|ci|build|revert)(\(.+\))?: .+")
|
||||
|
||||
DEFAULT_TIMEOUT = 30
|
||||
DEFAULT_PER_PAGE = 50
|
||||
|
||||
BRANCH_PROTECTION_CONFIG: dict[str, object] = {
|
||||
"branch_name": "master",
|
||||
"enable_push": True,
|
||||
"enable_push_whitelist": True,
|
||||
"push_whitelist_usernames": ["emil"],
|
||||
"enable_status_check": True,
|
||||
"status_check_contexts": [
|
||||
"CI / quality (pull_request)",
|
||||
"CI / molecule-tests (1) (pull_request)",
|
||||
"CI / molecule-tests (2) (pull_request)",
|
||||
"CI / molecule-tests (3) (pull_request)",
|
||||
],
|
||||
"required_approvals": 0,
|
||||
"dismiss_stale_approvals": True,
|
||||
"block_on_outdated_branch": True,
|
||||
"block_on_rejected_reviews": True,
|
||||
"block_on_official_review_requests": True,
|
||||
}
|
||||
|
||||
REPO_SETTINGS_CONFIG: dict[str, object] = {
|
||||
"default_delete_branch_after_merge": True,
|
||||
}
|
||||
@@ -11,12 +11,3 @@ class AnsibleError(GRMError):
|
||||
"""Raised when an Ansible command fails."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class APIError(GRMError):
|
||||
"""Raised when a REST API call returns an HTTP error."""
|
||||
|
||||
def __init__(self, status: int, message: str) -> None:
|
||||
self.status = status
|
||||
self.message = message
|
||||
super().__init__(f"HTTP {status}: {message}")
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
"""Minimal Gitea API client for workflow operations.
|
||||
|
||||
Uses urllib from the standard library to avoid adding requests as a
|
||||
runtime dependency. Only covers the Actions workflow dispatch endpoint.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import urllib.error
|
||||
import urllib.request # noqa: PTH123 # nosec B404
|
||||
from contextlib import suppress
|
||||
from typing import Any
|
||||
|
||||
|
||||
class GiteaAPIError(Exception):
|
||||
"""Raised when a Gitea API call fails."""
|
||||
|
||||
def __init__(self, status: int, message: str) -> None:
|
||||
super().__init__(f"Gitea API error {status}: {message}")
|
||||
self.status = status
|
||||
self.message = message
|
||||
|
||||
|
||||
class GiteaWorkflowClient:
|
||||
"""Thin client for Gitea Actions workflow API endpoints."""
|
||||
|
||||
def __init__(self, base_url: str, token: str) -> None:
|
||||
self._base_url = base_url.rstrip("/")
|
||||
self._token = token
|
||||
|
||||
def _request(self, method: str, path: str, body: dict[str, Any] | None = None) -> dict[str, Any] | None:
|
||||
url = f"{self._base_url}/api/v1{path}"
|
||||
data = json.dumps(body).encode("utf-8") if body else None
|
||||
req = urllib.request.Request( # nosec B310
|
||||
url,
|
||||
data=data,
|
||||
method=method,
|
||||
)
|
||||
req.add_header("Authorization", f"token {self._token}")
|
||||
req.add_header("Content-Type", "application/json")
|
||||
req.add_header("Accept", "application/json")
|
||||
try:
|
||||
with urllib.request.urlopen(req) as resp: # noqa: PTH123 # nosec B310
|
||||
if resp.status == 204:
|
||||
return None
|
||||
raw = resp.read()
|
||||
return json.loads(raw) if raw else None
|
||||
except urllib.error.HTTPError as e:
|
||||
detail = e.read().decode("utf-8", errors="replace")
|
||||
with suppress(json.JSONDecodeError, ValueError):
|
||||
detail = json.loads(detail).get("message", detail)
|
||||
raise GiteaAPIError(e.code, detail) from e
|
||||
|
||||
def list_workflows(self, owner: str, repo: str) -> list[dict[str, Any]]:
|
||||
"""List all workflows in a repository."""
|
||||
result = self._request("GET", f"/repos/{owner}/{repo}/actions/workflows")
|
||||
if result is None:
|
||||
return []
|
||||
return result.get("workflows", [])
|
||||
|
||||
def dispatch_workflow(
|
||||
self,
|
||||
owner: str,
|
||||
repo: str,
|
||||
workflow_id: str,
|
||||
ref: str = "master",
|
||||
inputs: dict[str, str] | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Trigger a workflow dispatch event.
|
||||
|
||||
Args:
|
||||
owner: Repository owner.
|
||||
repo: Repository name.
|
||||
workflow_id: Workflow file name (e.g. "ci.yml") or numeric ID.
|
||||
ref: Git ref (branch/tag) to run on. Defaults to "master".
|
||||
inputs: Optional workflow inputs.
|
||||
|
||||
Returns:
|
||||
Run details dict if return_run_details is requested, else None.
|
||||
"""
|
||||
path = f"/repos/{owner}/{repo}/actions/workflows/{workflow_id}/dispatches?return_run_details=true"
|
||||
body: dict[str, Any] = {"ref": ref}
|
||||
if inputs:
|
||||
body["inputs"] = inputs
|
||||
return self._request("POST", path, body)
|
||||
@@ -2,12 +2,11 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
from collections.abc import Generator
|
||||
from contextlib import contextmanager
|
||||
from contextlib import contextmanager, suppress
|
||||
from pathlib import Path
|
||||
|
||||
from .exceptions import AnsibleError
|
||||
@@ -47,7 +46,7 @@ class RunnerManager:
|
||||
json.dump(extra_vars, f)
|
||||
yield path
|
||||
finally:
|
||||
with contextlib.suppress(FileNotFoundError):
|
||||
with suppress(FileNotFoundError):
|
||||
os.unlink(path)
|
||||
|
||||
def _run_playbook(
|
||||
@@ -488,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)",
|
||||
@@ -630,5 +678,101 @@
|
||||
"pl": "nieznany",
|
||||
"ru": "неизвестно",
|
||||
"zh": "未知"
|
||||
},
|
||||
"CI_GITEA_TOKEN is required (set --token or CI_GITEA_TOKEN env var)": {
|
||||
"bg": "CI_GITEA_TOKEN е задължителен (задайте --token или CI_GITEA_TOKEN env var)",
|
||||
"de": "CI_GITEA_TOKEN ist erforderlich (setzen Sie --token oder CI_GITEA_TOKEN env var)",
|
||||
"en": "CI_GITEA_TOKEN is required (set --token or CI_GITEA_TOKEN env var)",
|
||||
"pl": "CI_GITEA_TOKEN jest wymagany (ustaw --token lub CI_GITEA_TOKEN env var)",
|
||||
"ru": "CI_GITEA_TOKEN обязателен (установите --token или CI_GITEA_TOKEN env var)",
|
||||
"zh": "需要 CI_GITEA_TOKEN(设置 --token 或 CI_GITEA_TOKEN 环境变量)"
|
||||
},
|
||||
"GITEA_URL is required (set --url or GITEA_URL env var)": {
|
||||
"bg": "GITEA_URL е задължителен (задайте --url или GITEA_URL env var)",
|
||||
"de": "GITEA_URL ist erforderlich (setzen Sie --url oder GITEA_URL env var)",
|
||||
"en": "GITEA_URL is required (set --url or GITEA_URL env var)",
|
||||
"pl": "GITEA_URL jest wymagany (ustaw --url lub GITEA_URL env var)",
|
||||
"ru": "GITEA_URL обязателен (установите --url или GITEA_URL env var)",
|
||||
"zh": "需要 GITEA_URL(设置 --url 或 GITEA_URL 环境变量)"
|
||||
},
|
||||
"Git ref to run the workflow on (default: master)": {
|
||||
"bg": "Git ref за изпълнение на работния процес (по подразбиране: master)",
|
||||
"de": "Git-Ref für die Workflow-Ausführung (Standard: master)",
|
||||
"en": "Git ref to run the workflow on (default: master)",
|
||||
"pl": "Git ref do uruchomienia workflow (domyślnie: master)",
|
||||
"ru": "Git ref для запуска workflow (по умолчанию: master)",
|
||||
"zh": "运行工作流的 Git ref(默认:master)"
|
||||
},
|
||||
"Gitea API token (env: CI_GITEA_TOKEN)": {
|
||||
"bg": "Gitea API токен (env: CI_GITEA_TOKEN)",
|
||||
"de": "Gitea API-Token (env: CI_GITEA_TOKEN)",
|
||||
"en": "Gitea API token (env: CI_GITEA_TOKEN)",
|
||||
"pl": "Token API Gitea (env: CI_GITEA_TOKEN)",
|
||||
"ru": "Токен API Gitea (env: CI_GITEA_TOKEN)",
|
||||
"zh": "Gitea API 令牌(环境变量:CI_GITEA_TOKEN)"
|
||||
},
|
||||
"List available workflows instead of triggering one": {
|
||||
"bg": "Списък на наличните работни процеси вместо изпълнение",
|
||||
"de": "Verfügbare Workflows auflisten statt auszuführen",
|
||||
"en": "List available workflows instead of triggering one",
|
||||
"pl": "Wyświetl dostępne workflow zamiast uruchamiać",
|
||||
"ru": "Список доступных workflow вместо запуска",
|
||||
"zh": "列出可用工作流而不是触发"
|
||||
},
|
||||
"No workflows found in {repo}": {
|
||||
"bg": "Няма намерени работни процеси в {repo}",
|
||||
"de": "Keine Workflows in {repo} gefunden",
|
||||
"en": "No workflows found in {repo}",
|
||||
"pl": "Nie znaleziono workflow w {repo}",
|
||||
"ru": "В {repo} не найдено workflow",
|
||||
"zh": "在 {repo} 中未找到工作流"
|
||||
},
|
||||
"Repository in owner/repo format (env: GRM_REPO, default: oblachno-oss/grm)": {
|
||||
"bg": "Хранилище във формат owner/repo (env: GRM_REPO, по подразбиране: oblachno-oss/grm)",
|
||||
"de": "Repository im owner/repo-Format (env: GRM_REPO, Standard: oblachno-oss/grm)",
|
||||
"en": "Repository in owner/repo format (env: GRM_REPO, default: oblachno-oss/grm)",
|
||||
"pl": "Repozytorium w formacie owner/repo (env: GRM_REPO, domyślnie: oblachno-oss/grm)",
|
||||
"ru": "Репозиторий в формате owner/repo (env: GRM_REPO, по умолчанию: oblachno-oss/grm)",
|
||||
"zh": "仓库格式为 owner/repo(环境变量:GRM_REPO,默认:oblachno-oss/grm)"
|
||||
},
|
||||
"Trigger a Gitea Actions workflow via the API.": {
|
||||
"bg": "Стартиране на Gitea Actions работен процес чрез API.",
|
||||
"de": "Einen Gitea Actions-Workflow über die API auslösen.",
|
||||
"en": "Trigger a Gitea Actions workflow via the API.",
|
||||
"pl": "Uruchom workflow Gitea Actions przez API.",
|
||||
"ru": "Запустить workflow Gitea Actions через API.",
|
||||
"zh": "通过 API 触发 Gitea Actions 工作流。"
|
||||
},
|
||||
"Triggering workflow {wf} on {repo}@{ref}...": {
|
||||
"bg": "Стартиране на работен процес {wf} в {repo}@{ref}...",
|
||||
"de": "Workflow {wf} auf {repo}@{ref} wird ausgelöst...",
|
||||
"en": "Triggering workflow {wf} on {repo}@{ref}...",
|
||||
"pl": "Uruchamianie workflow {wf} na {repo}@{ref}...",
|
||||
"ru": "Запуск workflow {wf} на {repo}@{ref}...",
|
||||
"zh": "正在触发工作流 {wf} 于 {repo}@{ref}..."
|
||||
},
|
||||
"WORKFLOW_ID is required unless --list is used": {
|
||||
"bg": "WORKFLOW_ID е задължителен, освен ако не се използва --list",
|
||||
"de": "WORKFLOW_ID ist erforderlich, es sei denn --list wird verwendet",
|
||||
"en": "WORKFLOW_ID is required unless --list is used",
|
||||
"pl": "WORKFLOW_ID jest wymagany, chyba że użyto --list",
|
||||
"ru": "WORKFLOW_ID обязателен, если не используется --list",
|
||||
"zh": "除非使用 --list,否则需要 WORKFLOW_ID"
|
||||
},
|
||||
"Workflow triggered successfully.": {
|
||||
"bg": "Работният процес е стартиран успешно.",
|
||||
"de": "Workflow erfolgreich ausgelöst.",
|
||||
"en": "Workflow triggered successfully.",
|
||||
"pl": "Workflow uruchomiony pomyślnie.",
|
||||
"ru": "Workflow успешно запущен.",
|
||||
"zh": "工作流触发成功。"
|
||||
},
|
||||
"Workflow triggered successfully. Run ID: {run_id}": {
|
||||
"bg": "Работният процес е стартиран успешно. ID на изпълнение: {run_id}",
|
||||
"de": "Workflow erfolgreich ausgelöst. Run-ID: {run_id}",
|
||||
"en": "Workflow triggered successfully. Run ID: {run_id}",
|
||||
"pl": "Workflow uruchomiony pomyślnie. ID uruchomienia: {run_id}",
|
||||
"ru": "Workflow успешно запущен. ID запуска: {run_id}",
|
||||
"zh": "工作流触发成功。运行 ID:{run_id}"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,642 +0,0 @@
|
||||
"""Unit tests for api_clients module."""
|
||||
|
||||
import http
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
from gitea_runner_manager.api_clients import GiteaClient, VikunjaClient, _is_retryable, _parse_error
|
||||
from gitea_runner_manager.config import (
|
||||
BRANCH_PROTECTION_CONFIG,
|
||||
DEFAULT_PER_PAGE,
|
||||
DEFAULT_TIMEOUT,
|
||||
VIKUNJA_PROJECT_ID,
|
||||
)
|
||||
from gitea_runner_manager.exceptions import APIError
|
||||
|
||||
|
||||
def _mock_response(json_data: object | None = None, raise_on_status: bool = False) -> MagicMock:
|
||||
mock = MagicMock()
|
||||
if json_data is not None:
|
||||
mock.json.return_value = json_data
|
||||
if raise_on_status:
|
||||
mock.raise_for_status.side_effect = requests.HTTPError(str(http.HTTPStatus.INTERNAL_SERVER_ERROR))
|
||||
return mock
|
||||
|
||||
|
||||
def _mock_http_error(status_code: int, message: str = "") -> requests.HTTPError:
|
||||
"""Create an HTTPError with a proper response attached (for _parse_error)."""
|
||||
resp = MagicMock()
|
||||
resp.status_code = status_code
|
||||
resp.json.return_value = {"message": message or str(status_code)}
|
||||
err = requests.HTTPError(f"{status_code} {message}", response=resp)
|
||||
return err
|
||||
|
||||
|
||||
class TestParseError:
|
||||
def test_json_parse_fallback(self) -> None:
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = http.HTTPStatus.BAD_GATEWAY
|
||||
mock_response.json = MagicMock(side_effect=ValueError("not json"))
|
||||
err = requests.HTTPError(str(http.HTTPStatus.BAD_GATEWAY), response=mock_response)
|
||||
status, message = _parse_error(err)
|
||||
assert status == http.HTTPStatus.BAD_GATEWAY
|
||||
assert str(http.HTTPStatus.BAD_GATEWAY) in message
|
||||
|
||||
def test_no_response(self) -> None:
|
||||
err = requests.HTTPError("connection failed")
|
||||
err.response = None # type: ignore[assignment]
|
||||
status, message = _parse_error(err)
|
||||
assert status == 0
|
||||
assert "connection failed" in message
|
||||
|
||||
|
||||
class TestGiteaClient:
|
||||
def test_init_sets_headers(self) -> None:
|
||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||
assert client._base_url == "https://git.example.com"
|
||||
assert client._session.headers["Authorization"] == "token tok"
|
||||
assert client._session.headers["Content-Type"] == "application/json"
|
||||
|
||||
def test_url_constructs_path(self) -> None:
|
||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||
assert client._url("/labels") == ("https://git.example.com/repos/owner/repo/labels")
|
||||
|
||||
def test_url_strips_trailing_slash(self) -> None:
|
||||
client = GiteaClient("https://git.example.com/", "tok", "owner", "repo")
|
||||
assert client._url("/labels") == ("https://git.example.com/repos/owner/repo/labels")
|
||||
|
||||
def test_list_labels(self) -> None:
|
||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||
client._session.request = MagicMock(return_value=_mock_response([{"name": "bug", "color": "ff0000"}]))
|
||||
result = client.list_labels()
|
||||
assert len(result) == 1
|
||||
client._session.request.assert_called_once_with(
|
||||
"GET",
|
||||
"https://git.example.com/repos/owner/repo/labels",
|
||||
timeout=DEFAULT_TIMEOUT,
|
||||
)
|
||||
|
||||
def test_list_labels_raises_api_error(self) -> None:
|
||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||
client._session.request = MagicMock(return_value=_mock_response(raise_on_status=True))
|
||||
with pytest.raises(APIError):
|
||||
client.list_labels()
|
||||
|
||||
def test_http_error_json_parse_fallback(self) -> None:
|
||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = http.HTTPStatus.BAD_GATEWAY
|
||||
# Make json() itself raise so the except block in _parse_error is hit
|
||||
mock_response.json = MagicMock(side_effect=ValueError("not json"))
|
||||
mock_response.raise_for_status.side_effect = requests.HTTPError(str(http.HTTPStatus.BAD_GATEWAY))
|
||||
client._session.request = MagicMock(return_value=mock_response)
|
||||
with pytest.raises(APIError) as exc_info:
|
||||
client.list_labels()
|
||||
assert str(http.HTTPStatus.BAD_GATEWAY) in str(exc_info.value)
|
||||
|
||||
def test_create_label(self) -> None:
|
||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||
client._session.request = MagicMock(return_value=_mock_response({"name": "ready-to-merge", "color": "2ecc71"}))
|
||||
result = client.create_label("ready-to-merge", "2ecc71", "Auto-merge label")
|
||||
assert result["name"] == "ready-to-merge"
|
||||
client._session.request.assert_called_once_with(
|
||||
"POST",
|
||||
"https://git.example.com/repos/owner/repo/labels",
|
||||
timeout=DEFAULT_TIMEOUT,
|
||||
json={"name": "ready-to-merge", "color": "2ecc71", "description": "Auto-merge label"},
|
||||
)
|
||||
|
||||
def test_ensure_label_creates_when_not_exists(self) -> None:
|
||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||
client.list_labels = MagicMock(return_value=[])
|
||||
client.create_label = MagicMock(return_value={"name": "ready-to-merge", "color": "2ecc71"})
|
||||
|
||||
result = client.ensure_label("ready-to-merge", "2ecc71", "desc")
|
||||
assert result is not None
|
||||
assert result["name"] == "ready-to-merge"
|
||||
client.create_label.assert_called_once_with("ready-to-merge", "2ecc71", "desc")
|
||||
|
||||
def test_ensure_label_returns_none_when_exists(self) -> None:
|
||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||
client.list_labels = MagicMock(return_value=[{"name": "ready-to-merge", "color": "2ecc71"}])
|
||||
client.create_label = MagicMock()
|
||||
|
||||
result = client.ensure_label("ready-to-merge", "2ecc71", "desc")
|
||||
assert result is None
|
||||
client.create_label.assert_not_called()
|
||||
|
||||
def test_list_branch_protections(self) -> None:
|
||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||
client._session.request = MagicMock(
|
||||
return_value=_mock_response(
|
||||
[
|
||||
{"id": 1, "branch_name": "master"},
|
||||
{"id": 2, "branch_name": "develop"},
|
||||
]
|
||||
)
|
||||
)
|
||||
result = client.list_branch_protections()
|
||||
assert len(result) == 2
|
||||
assert result[0]["branch_name"] == "master"
|
||||
|
||||
def test_create_branch_protection(self) -> None:
|
||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||
client._session.request = MagicMock(return_value=_mock_response({"id": 3, "branch_name": "master"}))
|
||||
result = client.create_branch_protection(BRANCH_PROTECTION_CONFIG)
|
||||
assert result["id"] == 3
|
||||
client._session.request.assert_called_once_with(
|
||||
"POST",
|
||||
"https://git.example.com/repos/owner/repo/branch_protections",
|
||||
timeout=DEFAULT_TIMEOUT,
|
||||
json=BRANCH_PROTECTION_CONFIG,
|
||||
)
|
||||
|
||||
def test_update_branch_protection(self) -> None:
|
||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||
resp = {"branch_name": "master", "required_approvals": 2}
|
||||
client._session.request = MagicMock(return_value=_mock_response(resp))
|
||||
update = {"required_approvals": 2}
|
||||
result = client.update_branch_protection("master", update)
|
||||
assert result["required_approvals"] == 2
|
||||
client._session.request.assert_called_once_with(
|
||||
"PATCH",
|
||||
"https://git.example.com/repos/owner/repo/branch_protections/master",
|
||||
timeout=DEFAULT_TIMEOUT,
|
||||
json=update,
|
||||
)
|
||||
|
||||
def test_ensure_branch_protection_creates_when_none_exist(self) -> None:
|
||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||
client.list_branch_protections = MagicMock(return_value=[])
|
||||
client.create_branch_protection = MagicMock(return_value={"id": 1, "branch_name": "master"})
|
||||
|
||||
result = client.ensure_branch_protection("master", BRANCH_PROTECTION_CONFIG)
|
||||
assert result["id"] == 1
|
||||
client.create_branch_protection.assert_called_once_with(BRANCH_PROTECTION_CONFIG)
|
||||
|
||||
def test_ensure_branch_protection_updates_when_exists(self) -> None:
|
||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||
client.list_branch_protections = MagicMock(return_value=[{"branch_name": "master", "required_approvals": 0}])
|
||||
client.update_branch_protection = MagicMock(return_value={"branch_name": "master", "required_approvals": 1})
|
||||
|
||||
result = client.ensure_branch_protection("master", BRANCH_PROTECTION_CONFIG)
|
||||
assert result["required_approvals"] == 1
|
||||
expected_update = {k: v for k, v in BRANCH_PROTECTION_CONFIG.items() if k != "branch_name"}
|
||||
client.update_branch_protection.assert_called_once_with("master", expected_update)
|
||||
|
||||
def test_merge_pr(self) -> None:
|
||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||
client._session.request = MagicMock(return_value=_mock_response())
|
||||
|
||||
client.merge_pr(1, "fix: bug")
|
||||
client._session.request.assert_called_once_with(
|
||||
"POST",
|
||||
"https://git.example.com/repos/owner/repo/pulls/1/merge",
|
||||
timeout=DEFAULT_TIMEOUT,
|
||||
json={"Do": "squash", "MergeTitleField": "fix: bug"},
|
||||
)
|
||||
|
||||
def test_get_pr_labels(self) -> None:
|
||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||
client._session.request = MagicMock(return_value=_mock_response([{"name": "ready-to-merge"}]))
|
||||
|
||||
result = client.get_pr_labels(5)
|
||||
assert result == [{"name": "ready-to-merge"}]
|
||||
client._session.request.assert_called_once_with(
|
||||
"GET",
|
||||
"https://git.example.com/repos/owner/repo/issues/5/labels",
|
||||
timeout=DEFAULT_TIMEOUT,
|
||||
)
|
||||
|
||||
def test_get_commit_status(self) -> None:
|
||||
"""Uses combined status endpoint (/status, not /statuses)."""
|
||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||
client._session.request = MagicMock(
|
||||
return_value=_mock_response({"statuses": [{"context": "CI / quality", "status": "success"}]})
|
||||
)
|
||||
|
||||
result = client.get_commit_status("abc123")
|
||||
assert result == [{"context": "CI / quality", "status": "success"}]
|
||||
client._session.request.assert_called_once_with(
|
||||
"GET",
|
||||
"https://git.example.com/repos/owner/repo/commits/abc123/status",
|
||||
timeout=DEFAULT_TIMEOUT,
|
||||
)
|
||||
|
||||
def test_get_pr(self) -> None:
|
||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||
client._session.request = MagicMock(return_value=_mock_response({"number": 7, "head": {"sha": "abc123"}}))
|
||||
|
||||
result = client.get_pr(7)
|
||||
assert result["number"] == 7
|
||||
assert result["head"]["sha"] == "abc123"
|
||||
client._session.request.assert_called_once_with(
|
||||
"GET",
|
||||
"https://git.example.com/repos/owner/repo/pulls/7",
|
||||
timeout=DEFAULT_TIMEOUT,
|
||||
)
|
||||
|
||||
def test_get_pr_files(self) -> None:
|
||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||
client._session.request = MagicMock(
|
||||
return_value=_mock_response([{"filename": "src/main.py", "status": "modified"}])
|
||||
)
|
||||
|
||||
result = client.get_pr_files(7)
|
||||
assert len(result) == 1
|
||||
assert result[0]["filename"] == "src/main.py"
|
||||
client._session.request.assert_called_once_with(
|
||||
"GET",
|
||||
"https://git.example.com/repos/owner/repo/pulls/7/files",
|
||||
timeout=DEFAULT_TIMEOUT,
|
||||
)
|
||||
|
||||
def test_get_pr_commits(self) -> None:
|
||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||
client._session.request = MagicMock(
|
||||
return_value=_mock_response([{"sha": "abc123", "commit": {"message": "fix: bug"}}])
|
||||
)
|
||||
|
||||
result = client.get_pr_commits(7)
|
||||
assert len(result) == 1
|
||||
assert result[0]["commit"]["message"] == "fix: bug"
|
||||
client._session.request.assert_called_once_with(
|
||||
"GET",
|
||||
"https://git.example.com/repos/owner/repo/pulls/7/commits",
|
||||
timeout=DEFAULT_TIMEOUT,
|
||||
)
|
||||
|
||||
def test_get_pr_reviews(self) -> None:
|
||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||
client._session.request = MagicMock(return_value=_mock_response([{"id": 1, "state": "APPROVED"}]))
|
||||
|
||||
result = client.get_pr_reviews(7)
|
||||
assert len(result) == 1
|
||||
assert result[0]["state"] == "APPROVED"
|
||||
client._session.request.assert_called_once_with(
|
||||
"GET",
|
||||
"https://git.example.com/repos/owner/repo/pulls/7/reviews",
|
||||
timeout=DEFAULT_TIMEOUT,
|
||||
)
|
||||
|
||||
def test_create_issue(self) -> None:
|
||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||
client._session.request = MagicMock(return_value=_mock_response({"id": 42, "title": "bug"}))
|
||||
|
||||
result = client.create_issue(title="bug", body="description", labels=[1])
|
||||
assert result["id"] == 42
|
||||
client._session.request.assert_called_once_with(
|
||||
"POST",
|
||||
"https://git.example.com/repos/owner/repo/issues",
|
||||
json={"title": "bug", "body": "description", "labels": [1]},
|
||||
timeout=DEFAULT_TIMEOUT,
|
||||
)
|
||||
|
||||
def test_create_issue_no_labels(self) -> None:
|
||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||
client._session.request = MagicMock(return_value=_mock_response({"id": 43, "title": "bug"}))
|
||||
|
||||
result = client.create_issue(title="bug", body="description")
|
||||
assert result["id"] == 43
|
||||
client._session.request.assert_called_once_with(
|
||||
"POST",
|
||||
"https://git.example.com/repos/owner/repo/issues",
|
||||
json={"title": "bug", "body": "description"},
|
||||
timeout=DEFAULT_TIMEOUT,
|
||||
)
|
||||
|
||||
def test_create_review_comment(self) -> None:
|
||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||
client._session.request = MagicMock(return_value=_mock_response({"id": 42}))
|
||||
|
||||
result = client.create_review(7, event="COMMENT", body="Looks good")
|
||||
assert result["id"] == 42
|
||||
client._session.request.assert_called_once_with(
|
||||
"POST",
|
||||
"https://git.example.com/repos/owner/repo/pulls/7/reviews",
|
||||
timeout=DEFAULT_TIMEOUT,
|
||||
json={"event": "COMMENT", "body": "Looks good"},
|
||||
)
|
||||
|
||||
def test_create_review_approve_maps_to_approved(self) -> None:
|
||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||
client._session.request = MagicMock(return_value=_mock_response({"id": 44, "state": "APPROVED"}))
|
||||
|
||||
result = client.create_review(7, event="APPROVE", body="Good work")
|
||||
assert result["id"] == 44
|
||||
client._session.request.assert_called_once_with(
|
||||
"POST",
|
||||
"https://git.example.com/repos/owner/repo/pulls/7/reviews",
|
||||
timeout=DEFAULT_TIMEOUT,
|
||||
json={"event": "APPROVED", "body": "Good work"},
|
||||
)
|
||||
|
||||
def test_create_review_with_inline_comments(self) -> None:
|
||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||
client._session.request = MagicMock(return_value=_mock_response({"id": 43}))
|
||||
|
||||
comments = [{"path": "src/main.py", "body": "Fix this", "new_position": 10}]
|
||||
result = client.create_review(7, event="REQUEST_CHANGES", body="Please fix", comments=comments)
|
||||
assert result["id"] == 43
|
||||
client._session.request.assert_called_once_with(
|
||||
"POST",
|
||||
"https://git.example.com/repos/owner/repo/pulls/7/reviews",
|
||||
timeout=DEFAULT_TIMEOUT,
|
||||
json={"event": "REQUEST_CHANGES", "body": "Please fix", "comments": comments},
|
||||
)
|
||||
|
||||
def test_update_repo_settings(self) -> None:
|
||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||
client._session.request = MagicMock(return_value=_mock_response({"default_delete_branch_after_merge": True}))
|
||||
|
||||
settings = {"default_delete_branch_after_merge": True}
|
||||
result = client.update_repo_settings(settings)
|
||||
assert result["default_delete_branch_after_merge"] is True
|
||||
client._session.request.assert_called_once_with(
|
||||
"PATCH",
|
||||
"https://git.example.com/repos/owner/repo",
|
||||
timeout=DEFAULT_TIMEOUT,
|
||||
json=settings,
|
||||
)
|
||||
|
||||
def test_create_release(self) -> None:
|
||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||
client._session.request = MagicMock(return_value=_mock_response({"id": 1}))
|
||||
|
||||
client.create_release("v1.0.0")
|
||||
client._session.request.assert_called_once_with(
|
||||
"POST",
|
||||
"https://git.example.com/repos/owner/repo/releases",
|
||||
timeout=DEFAULT_TIMEOUT,
|
||||
json={"tag_name": "v1.0.0", "name": "v1.0.0", "body": "", "draft": False, "prerelease": False},
|
||||
)
|
||||
|
||||
def test_get_release_by_tag_found(self) -> None:
|
||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||
client._session.request = MagicMock(return_value=_mock_response({"id": 1, "tag_name": "v1.0.0"}))
|
||||
result = client.get_release_by_tag("v1.0.0")
|
||||
assert result is not None
|
||||
assert result["id"] == 1
|
||||
|
||||
def test_get_release_by_tag_not_found(self) -> None:
|
||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.raise_for_status.side_effect = requests.HTTPError("404")
|
||||
mock_resp.status_code = 404
|
||||
client._session.request = MagicMock(return_value=mock_resp)
|
||||
result = client.get_release_by_tag("v9.9.9")
|
||||
assert result is None
|
||||
|
||||
def test_create_release_idempotent_existing(self) -> None:
|
||||
"""If release already exists, should return it without creating a new one."""
|
||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||
existing_response = _mock_response({"id": 42, "tag_name": "v1.0.0"})
|
||||
client._session.request = MagicMock(return_value=existing_response)
|
||||
result = client.create_release_idempotent("v1.0.0")
|
||||
assert result["id"] == 42
|
||||
# Should only call GET (check), not POST (create)
|
||||
assert client._session.request.call_count == 1
|
||||
assert client._session.request.call_args[0][0] == "GET"
|
||||
|
||||
def test_create_release_idempotent_new(self) -> None:
|
||||
"""If release doesn't exist, should create it."""
|
||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||
not_found_resp = MagicMock()
|
||||
not_found_resp.raise_for_status.side_effect = requests.HTTPError("404")
|
||||
not_found_resp.status_code = 404
|
||||
create_resp = _mock_response({"id": 1, "tag_name": "v1.0.0"})
|
||||
client._session.request = MagicMock(side_effect=[not_found_resp, create_resp])
|
||||
result = client.create_release_idempotent("v1.0.0")
|
||||
assert result["id"] == 1
|
||||
assert client._session.request.call_count == 2
|
||||
|
||||
@patch("gitea_runner_manager.api_clients.time.sleep")
|
||||
def test_request_retries_on_429(self, mock_sleep: MagicMock) -> None:
|
||||
"""Should retry on 429 rate limit with exponential backoff."""
|
||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||
rate_limited = MagicMock()
|
||||
rate_limited.raise_for_status.side_effect = _mock_http_error(429, "rate limited")
|
||||
success = _mock_response({"ok": True})
|
||||
client._session.request = MagicMock(side_effect=[rate_limited, rate_limited, success])
|
||||
result = client._request("GET", "/test")
|
||||
assert result.json() == {"ok": True}
|
||||
assert client._session.request.call_count == 3
|
||||
assert mock_sleep.call_count == 2
|
||||
|
||||
@patch("gitea_runner_manager.api_clients.time.sleep")
|
||||
def test_request_retries_on_503(self, mock_sleep: MagicMock) -> None:
|
||||
"""Should retry on 503 service unavailable."""
|
||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||
unavailable = MagicMock()
|
||||
unavailable.raise_for_status.side_effect = _mock_http_error(503, "unavailable")
|
||||
success = _mock_response({"ok": True})
|
||||
client._session.request = MagicMock(side_effect=[unavailable, success])
|
||||
result = client._request("GET", "/test")
|
||||
assert result.json() == {"ok": True}
|
||||
assert client._session.request.call_count == 2
|
||||
|
||||
@patch("gitea_runner_manager.api_clients.time.sleep")
|
||||
def test_request_no_retry_on_404(self, mock_sleep: MagicMock) -> None:
|
||||
"""Should NOT retry on 404 — it's not a transient error."""
|
||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||
not_found = MagicMock()
|
||||
not_found.raise_for_status.side_effect = _mock_http_error(404, "not found")
|
||||
client._session.request = MagicMock(return_value=not_found)
|
||||
with pytest.raises(APIError) as exc_info:
|
||||
client._request("GET", "/test")
|
||||
assert exc_info.value.status == 404
|
||||
assert client._session.request.call_count == 1
|
||||
mock_sleep.assert_not_called()
|
||||
|
||||
@patch("gitea_runner_manager.api_clients.time.sleep")
|
||||
def test_request_retries_on_connection_error(self, mock_sleep: MagicMock) -> None:
|
||||
"""Should retry on connection errors."""
|
||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||
success = _mock_response({"ok": True})
|
||||
client._session.request = MagicMock(side_effect=[requests.ConnectionError("refused"), success])
|
||||
result = client._request("GET", "/test")
|
||||
assert result.json() == {"ok": True}
|
||||
assert client._session.request.call_count == 2
|
||||
|
||||
@patch("gitea_runner_manager.api_clients.time.sleep")
|
||||
def test_request_max_retries_exhausted(self, mock_sleep: MagicMock) -> None:
|
||||
"""Should raise APIError after max retries on persistent 503."""
|
||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||
unavailable = MagicMock()
|
||||
unavailable.raise_for_status.side_effect = _mock_http_error(503, "unavailable")
|
||||
client._session.request = MagicMock(return_value=unavailable)
|
||||
with pytest.raises(APIError) as exc_info:
|
||||
client._request("GET", "/test")
|
||||
assert exc_info.value.status == 503
|
||||
assert client._session.request.call_count == 3 # MAX_RETRIES
|
||||
|
||||
@patch("gitea_runner_manager.api_clients.time.sleep")
|
||||
def test_request_connection_error_exhausted(self, mock_sleep: MagicMock) -> None:
|
||||
"""Should raise APIError after max retries on persistent connection errors."""
|
||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||
client._session.request = MagicMock(side_effect=requests.ConnectionError("refused"))
|
||||
with pytest.raises(APIError) as exc_info:
|
||||
client._request("GET", "/test")
|
||||
assert exc_info.value.status == 0
|
||||
assert client._session.request.call_count == 3 # MAX_RETRIES
|
||||
|
||||
|
||||
class TestVikunjaClient:
|
||||
def test_init_sets_headers(self) -> None:
|
||||
client = VikunjaClient("https://work.example.com", "tok")
|
||||
assert client._base_url == "https://work.example.com"
|
||||
assert client._session.headers["Authorization"] == "Bearer tok"
|
||||
|
||||
def test_list_tasks(self) -> None:
|
||||
client = VikunjaClient("https://work.example.com", "tok")
|
||||
client._session.request = MagicMock(
|
||||
return_value=_mock_response([{"id": 1, "identifier": "GRM-19", "project_id": VIKUNJA_PROJECT_ID}])
|
||||
)
|
||||
|
||||
result = client.list_tasks(per_page=DEFAULT_PER_PAGE)
|
||||
assert len(result) == 1
|
||||
client._session.request.assert_called_once_with(
|
||||
"GET",
|
||||
"https://work.example.com/tasks",
|
||||
timeout=DEFAULT_TIMEOUT,
|
||||
params={"per_page": DEFAULT_PER_PAGE},
|
||||
)
|
||||
|
||||
def test_list_project_tasks(self) -> None:
|
||||
client = VikunjaClient("https://work.example.com", "tok")
|
||||
client._session.request = MagicMock(return_value=_mock_response([{"id": 1, "identifier": "GRM-19"}]))
|
||||
|
||||
result = client.list_project_tasks(VIKUNJA_PROJECT_ID, page=1, per_page=DEFAULT_PER_PAGE)
|
||||
assert len(result) == 1
|
||||
client._session.request.assert_called_once_with(
|
||||
"GET",
|
||||
f"https://work.example.com/projects/{VIKUNJA_PROJECT_ID}/tasks",
|
||||
timeout=DEFAULT_TIMEOUT,
|
||||
params={"page": 1, "per_page": DEFAULT_PER_PAGE},
|
||||
)
|
||||
|
||||
def test_get_task(self) -> None:
|
||||
client = VikunjaClient("https://work.example.com", "tok")
|
||||
client._session.request = MagicMock(
|
||||
return_value=_mock_response({"id": 292, "identifier": "GRM-32", "title": "Some task"})
|
||||
)
|
||||
|
||||
result = client.get_task(292)
|
||||
assert result["identifier"] == "GRM-32"
|
||||
assert result["title"] == "Some task"
|
||||
client._session.request.assert_called_once_with(
|
||||
"GET",
|
||||
"https://work.example.com/tasks/292",
|
||||
timeout=DEFAULT_TIMEOUT,
|
||||
)
|
||||
|
||||
def test_post_comment(self) -> None:
|
||||
client = VikunjaClient("https://work.example.com", "tok")
|
||||
client._session.request = MagicMock(return_value=_mock_response())
|
||||
|
||||
client.post_comment(42, "<p>hi</p>")
|
||||
client._session.request.assert_called_once_with(
|
||||
"PUT",
|
||||
"https://work.example.com/tasks/42/comments",
|
||||
timeout=DEFAULT_TIMEOUT,
|
||||
json={"comment": "<p>hi</p>"},
|
||||
)
|
||||
|
||||
def test_update_task(self) -> None:
|
||||
client = VikunjaClient("https://work.example.com", "tok")
|
||||
client._session.request = MagicMock(return_value=_mock_response())
|
||||
|
||||
client.update_task(42, done=True)
|
||||
client._session.request.assert_called_once_with(
|
||||
"POST",
|
||||
"https://work.example.com/tasks/42",
|
||||
timeout=DEFAULT_TIMEOUT,
|
||||
json={"done": True},
|
||||
)
|
||||
|
||||
@patch("gitea_runner_manager.api_clients.time.sleep")
|
||||
def test_http_error_raises_api_error(self, mock_sleep: MagicMock) -> None:
|
||||
client = VikunjaClient("https://work.example.com", "tok")
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.raise_for_status.side_effect = _mock_http_error(http.HTTPStatus.INTERNAL_SERVER_ERROR, "server error")
|
||||
client._session.request = MagicMock(return_value=mock_resp)
|
||||
|
||||
with pytest.raises(APIError):
|
||||
client.list_tasks()
|
||||
|
||||
def test_http_error_no_response(self) -> None:
|
||||
client = VikunjaClient("https://work.example.com", "tok")
|
||||
err = requests.HTTPError("connection failed")
|
||||
err.response = None # type: ignore[assignment]
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.raise_for_status.side_effect = err
|
||||
client._session.request = MagicMock(return_value=mock_resp)
|
||||
|
||||
with pytest.raises(APIError) as exc_info:
|
||||
client.list_tasks()
|
||||
assert "connection failed" in str(exc_info.value)
|
||||
|
||||
@patch("gitea_runner_manager.api_clients.time.sleep")
|
||||
def test_vikunja_retries_on_503(self, mock_sleep: MagicMock) -> None:
|
||||
"""VikunjaClient should also retry on 503."""
|
||||
client = VikunjaClient("https://work.example.com", "tok")
|
||||
unavailable = MagicMock()
|
||||
unavailable.raise_for_status.side_effect = _mock_http_error(503, "unavailable")
|
||||
success = _mock_response([{"id": 1}])
|
||||
client._session.request = MagicMock(side_effect=[unavailable, success])
|
||||
result = client.list_tasks()
|
||||
assert len(result) == 1
|
||||
assert client._session.request.call_count == 2
|
||||
|
||||
@patch("gitea_runner_manager.api_clients.time.sleep")
|
||||
def test_vikunja_retries_on_connection_error(self, mock_sleep: MagicMock) -> None:
|
||||
"""VikunjaClient should retry on connection errors."""
|
||||
client = VikunjaClient("https://work.example.com", "tok")
|
||||
success = _mock_response([{"id": 1}])
|
||||
client._session.request = MagicMock(side_effect=[requests.ConnectionError("refused"), success])
|
||||
result = client.list_tasks()
|
||||
assert len(result) == 1
|
||||
assert client._session.request.call_count == 2
|
||||
|
||||
@patch("gitea_runner_manager.api_clients.time.sleep")
|
||||
def test_vikunja_max_retries_exhausted(self, mock_sleep: MagicMock) -> None:
|
||||
"""VikunjaClient should raise APIError after max retries on persistent 503."""
|
||||
client = VikunjaClient("https://work.example.com", "tok")
|
||||
unavailable = MagicMock()
|
||||
unavailable.raise_for_status.side_effect = _mock_http_error(503, "unavailable")
|
||||
client._session.request = MagicMock(return_value=unavailable)
|
||||
with pytest.raises(APIError) as exc_info:
|
||||
client.list_tasks()
|
||||
assert exc_info.value.status == 503
|
||||
assert client._session.request.call_count == 3 # MAX_RETRIES
|
||||
|
||||
@patch("gitea_runner_manager.api_clients.time.sleep")
|
||||
def test_vikunja_connection_error_exhausted(self, mock_sleep: MagicMock) -> None:
|
||||
"""VikunjaClient should raise APIError after max retries on persistent connection errors."""
|
||||
client = VikunjaClient("https://work.example.com", "tok")
|
||||
client._session.request = MagicMock(side_effect=requests.ConnectionError("refused"))
|
||||
with pytest.raises(APIError) as exc_info:
|
||||
client.list_tasks()
|
||||
assert exc_info.value.status == 0
|
||||
assert client._session.request.call_count == 3 # MAX_RETRIES
|
||||
|
||||
|
||||
class TestIsRetryable:
|
||||
def test_connection_error_is_retryable(self) -> None:
|
||||
assert _is_retryable(requests.ConnectionError("refused")) is True
|
||||
|
||||
def test_timeout_is_retryable(self) -> None:
|
||||
assert _is_retryable(requests.Timeout("timed out")) is True
|
||||
|
||||
def test_429_is_retryable(self) -> None:
|
||||
err = _mock_http_error(429, "rate limited")
|
||||
assert _is_retryable(err) is True
|
||||
|
||||
def test_404_is_not_retryable(self) -> None:
|
||||
err = _mock_http_error(404, "not found")
|
||||
assert _is_retryable(err) is False
|
||||
|
||||
def test_generic_exception_is_not_retryable(self) -> None:
|
||||
assert _is_retryable(ValueError("oops")) is False
|
||||
@@ -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(
|
||||
@@ -918,3 +987,91 @@ class TestCLI:
|
||||
|
||||
with patch.dict("os.environ", {"ANSIBLE_BECOME_PASSWORD_FILE": "/tmp/ansible.txt"}, clear=True):
|
||||
assert _get_become_password_file() == "/tmp/ansible.txt"
|
||||
|
||||
|
||||
class TestTriggerWorkflow:
|
||||
"""Tests for the trigger-workflow CLI command."""
|
||||
|
||||
def test_trigger_workflow_success(self) -> None:
|
||||
runner = CliRunner(env=_TEST_ENV)
|
||||
with patch("gitea_runner_manager.cli.GiteaWorkflowClient") as mock_client_cls:
|
||||
mock_client = MagicMock()
|
||||
mock_client_cls.return_value = mock_client
|
||||
mock_client.dispatch_workflow.return_value = {
|
||||
"id": 42,
|
||||
"html_url": "https://git.example.com/oblachno-oss/grm/actions/runs/42",
|
||||
}
|
||||
result = runner.invoke(cli, ["trigger-workflow", "ci.yml", "--token", "tok"])
|
||||
assert result.exit_code == 0
|
||||
assert "42" in result.output
|
||||
mock_client.dispatch_workflow.assert_called_once_with("oblachno-oss", "grm", "ci.yml", "master")
|
||||
|
||||
def test_trigger_workflow_no_url(self) -> None:
|
||||
runner = CliRunner()
|
||||
with patch.dict("os.environ", {}, clear=True):
|
||||
result = runner.invoke(cli, ["trigger-workflow", "ci.yml", "--token", "tok"])
|
||||
assert result.exit_code != 0
|
||||
assert "GITEA_URL" in result.output
|
||||
|
||||
def test_trigger_workflow_no_token(self) -> None:
|
||||
runner = CliRunner()
|
||||
with patch.dict("os.environ", {"GITEA_URL": "https://git.example.com"}, clear=True):
|
||||
result = runner.invoke(cli, ["trigger-workflow", "ci.yml"])
|
||||
assert result.exit_code != 0
|
||||
assert "CI_GITEA_TOKEN" in result.output
|
||||
|
||||
def test_trigger_workflow_list(self) -> None:
|
||||
runner = CliRunner(env=_TEST_ENV)
|
||||
with patch("gitea_runner_manager.cli.GiteaWorkflowClient") as mock_client_cls:
|
||||
mock_client = MagicMock()
|
||||
mock_client_cls.return_value = mock_client
|
||||
mock_client.list_workflows.return_value = [
|
||||
{"id": 1, "name": "CI", "path": "ci.yml", "state": "active"},
|
||||
{"id": 2, "name": "Post-merge", "path": "post-merge.yml", "state": "active"},
|
||||
]
|
||||
result = runner.invoke(cli, ["trigger-workflow", "--list", "--token", "tok"])
|
||||
assert result.exit_code == 0
|
||||
assert "CI" in result.output
|
||||
assert "Post-merge" in result.output
|
||||
mock_client.list_workflows.assert_called_once_with("oblachno-oss", "grm")
|
||||
|
||||
def test_trigger_workflow_list_empty(self) -> None:
|
||||
runner = CliRunner(env=_TEST_ENV)
|
||||
with patch("gitea_runner_manager.cli.GiteaWorkflowClient") as mock_client_cls:
|
||||
mock_client = MagicMock()
|
||||
mock_client_cls.return_value = mock_client
|
||||
mock_client.list_workflows.return_value = []
|
||||
result = runner.invoke(cli, ["trigger-workflow", "--list", "--token", "tok"])
|
||||
assert result.exit_code == 0
|
||||
assert "No workflows" in result.output
|
||||
|
||||
def test_trigger_workflow_no_workflow_id(self) -> None:
|
||||
runner = CliRunner(env=_TEST_ENV)
|
||||
result = runner.invoke(cli, ["trigger-workflow", "--token", "tok"])
|
||||
assert result.exit_code != 0
|
||||
assert "WORKFLOW_ID" in result.output
|
||||
|
||||
def test_trigger_workflow_api_error(self) -> None:
|
||||
from gitea_runner_manager.gitea_client import GiteaAPIError
|
||||
|
||||
runner = CliRunner(env=_TEST_ENV)
|
||||
with patch("gitea_runner_manager.cli.GiteaWorkflowClient") as mock_client_cls:
|
||||
mock_client = MagicMock()
|
||||
mock_client_cls.return_value = mock_client
|
||||
mock_client.dispatch_workflow.side_effect = GiteaAPIError(404, "workflow not found")
|
||||
result = runner.invoke(cli, ["trigger-workflow", "nonexistent.yml", "--token", "tok"])
|
||||
assert result.exit_code != 0
|
||||
assert "404" in result.output
|
||||
|
||||
def test_trigger_workflow_custom_repo_and_ref(self) -> None:
|
||||
runner = CliRunner(env=_TEST_ENV)
|
||||
with patch("gitea_runner_manager.cli.GiteaWorkflowClient") as mock_client_cls:
|
||||
mock_client = MagicMock()
|
||||
mock_client_cls.return_value = mock_client
|
||||
mock_client.dispatch_workflow.return_value = None
|
||||
result = runner.invoke(
|
||||
cli,
|
||||
["trigger-workflow", "build.yml", "--repo", "myorg/myrepo", "--ref", "develop", "--token", "tok"],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
mock_client.dispatch_workflow.assert_called_once_with("myorg", "myrepo", "build.yml", "develop")
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
"""Unit tests for config module constants."""
|
||||
|
||||
from gitea_runner_manager.config import (
|
||||
BRANCH_PROTECTION_CONFIG,
|
||||
CONVENTIONAL_RE,
|
||||
DEFAULT_PER_PAGE,
|
||||
DEFAULT_TIMEOUT,
|
||||
GITEA_API_URL,
|
||||
REPO_NAME,
|
||||
REPO_OWNER,
|
||||
TASK_ID_RE,
|
||||
VIKUNJA_API_URL,
|
||||
VIKUNJA_PROJECT_ID,
|
||||
)
|
||||
|
||||
|
||||
class TestConfigConstants:
|
||||
def test_api_urls(self) -> None:
|
||||
assert "api/v1" in GITEA_API_URL
|
||||
assert "api/v1" in VIKUNJA_API_URL
|
||||
|
||||
def test_project_ids(self) -> None:
|
||||
assert VIKUNJA_PROJECT_ID == 6
|
||||
|
||||
def test_timeouts(self) -> None:
|
||||
assert DEFAULT_TIMEOUT == 30
|
||||
assert DEFAULT_PER_PAGE == 50
|
||||
|
||||
def test_owner_and_repo(self) -> None:
|
||||
assert REPO_OWNER == "oblachno-oss"
|
||||
assert REPO_NAME == "grm"
|
||||
|
||||
def test_task_id_re(self) -> None:
|
||||
assert TASK_ID_RE.search("GRM-1")
|
||||
assert TASK_ID_RE.search("GRM-123")
|
||||
assert not TASK_ID_RE.search("GRM-")
|
||||
assert not TASK_ID_RE.search("other text")
|
||||
|
||||
def test_conventional_re(self) -> None:
|
||||
assert CONVENTIONAL_RE.match("feat: add feature")
|
||||
assert CONVENTIONAL_RE.match("fix(scope): bug fix")
|
||||
assert not CONVENTIONAL_RE.match("random message")
|
||||
assert not CONVENTIONAL_RE.match("feat:")
|
||||
assert not CONVENTIONAL_RE.match("BREAKING CHANGE: something")
|
||||
|
||||
def test_branch_protection_config(self) -> None:
|
||||
assert BRANCH_PROTECTION_CONFIG["branch_name"] == "master"
|
||||
assert BRANCH_PROTECTION_CONFIG["enable_push"] is True
|
||||
assert BRANCH_PROTECTION_CONFIG["enable_push_whitelist"] is True
|
||||
assert "emil" in BRANCH_PROTECTION_CONFIG["push_whitelist_usernames"]
|
||||
assert BRANCH_PROTECTION_CONFIG["required_approvals"] == 0
|
||||
contexts = BRANCH_PROTECTION_CONFIG["status_check_contexts"]
|
||||
assert isinstance(contexts, list)
|
||||
assert len(contexts) == 4
|
||||
assert "CI / quality (pull_request)" in contexts
|
||||
assert any("molecule-tests" in c for c in contexts)
|
||||
@@ -0,0 +1,120 @@
|
||||
"""Unit tests for gitea_client module."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from gitea_runner_manager.gitea_client import GiteaAPIError, GiteaWorkflowClient
|
||||
|
||||
|
||||
class TestGiteaWorkflowClient:
|
||||
def _client(self) -> GiteaWorkflowClient:
|
||||
return GiteaWorkflowClient("https://git.example.com", "test-token")
|
||||
|
||||
def test_list_workflows(self) -> None:
|
||||
client = self._client()
|
||||
mock_response = {"workflows": [{"id": 1, "name": "CI", "path": "ci.yml", "state": "active"}]}
|
||||
with patch.object(client, "_request", return_value=mock_response) as mock_req:
|
||||
result = client.list_workflows("oblachno-oss", "grm")
|
||||
assert len(result) == 1
|
||||
assert result[0]["name"] == "CI"
|
||||
mock_req.assert_called_once_with("GET", "/repos/oblachno-oss/grm/actions/workflows")
|
||||
|
||||
def test_list_workflows_empty(self) -> None:
|
||||
client = self._client()
|
||||
with patch.object(client, "_request", return_value=None):
|
||||
result = client.list_workflows("oblachno-oss", "grm")
|
||||
assert result == []
|
||||
|
||||
def test_dispatch_workflow(self) -> None:
|
||||
client = self._client()
|
||||
mock_response = {"id": 42, "html_url": "https://git.example.com/oblachno-oss/grm/actions/runs/42"}
|
||||
with patch.object(client, "_request", return_value=mock_response) as mock_req:
|
||||
result = client.dispatch_workflow("oblachno-oss", "grm", "ci.yml", ref="master")
|
||||
assert result is not None
|
||||
assert result["id"] == 42
|
||||
mock_req.assert_called_once_with(
|
||||
"POST",
|
||||
"/repos/oblachno-oss/grm/actions/workflows/ci.yml/dispatches?return_run_details=true",
|
||||
{"ref": "master"},
|
||||
)
|
||||
|
||||
def test_dispatch_workflow_with_inputs(self) -> None:
|
||||
client = self._client()
|
||||
with patch.object(client, "_request", return_value=None) as mock_req:
|
||||
client.dispatch_workflow("oblachno-oss", "grm", "build.yml", ref="master", inputs={"env": "prod"})
|
||||
mock_req.assert_called_once_with(
|
||||
"POST",
|
||||
"/repos/oblachno-oss/grm/actions/workflows/build.yml/dispatches?return_run_details=true",
|
||||
{"ref": "master", "inputs": {"env": "prod"}},
|
||||
)
|
||||
|
||||
def test_dispatch_workflow_api_error(self) -> None:
|
||||
client = self._client()
|
||||
with patch.object(client, "_request", side_effect=GiteaAPIError(404, "workflow not found")):
|
||||
with pytest.raises(GiteaAPIError) as exc_info:
|
||||
client.dispatch_workflow("oblachno-oss", "grm", "nonexistent.yml")
|
||||
assert exc_info.value.status == 404
|
||||
|
||||
|
||||
class TestGiteaWorkflowClientRequest:
|
||||
"""Test the underlying _request method with mocked urllib."""
|
||||
|
||||
def test_request_success(self) -> None:
|
||||
client = GiteaWorkflowClient("https://git.example.com/", "tok")
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status = 200
|
||||
mock_resp.read.return_value = json.dumps({"ok": True}).encode()
|
||||
mock_resp.__enter__ = MagicMock(return_value=mock_resp)
|
||||
mock_resp.__exit__ = MagicMock(return_value=False)
|
||||
with patch("urllib.request.urlopen", return_value=mock_resp) as mock_urlopen:
|
||||
result = client._request("GET", "/test")
|
||||
assert result == {"ok": True}
|
||||
mock_urlopen.assert_called_once()
|
||||
|
||||
def test_request_204_no_content(self) -> None:
|
||||
client = GiteaWorkflowClient("https://git.example.com", "tok")
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status = 204
|
||||
mock_resp.__enter__ = MagicMock(return_value=mock_resp)
|
||||
mock_resp.__exit__ = MagicMock(return_value=False)
|
||||
with patch("urllib.request.urlopen", return_value=mock_resp):
|
||||
result = client._request("POST", "/test", {"ref": "master"})
|
||||
assert result is None
|
||||
|
||||
def test_request_http_error(self) -> None:
|
||||
import urllib.error
|
||||
|
||||
client = GiteaWorkflowClient("https://git.example.com", "tok")
|
||||
err = urllib.error.HTTPError(
|
||||
"https://git.example.com/api/v1/test",
|
||||
404,
|
||||
"Not Found",
|
||||
{},
|
||||
__import__("io").BytesIO(b'{"message": "resource not found"}'),
|
||||
)
|
||||
with patch("urllib.request.urlopen", side_effect=err):
|
||||
with pytest.raises(GiteaAPIError) as exc_info:
|
||||
client._request("GET", "/test")
|
||||
assert exc_info.value.status == 404
|
||||
assert "resource not found" in exc_info.value.message
|
||||
|
||||
def test_request_http_error_non_json(self) -> None:
|
||||
import urllib.error
|
||||
|
||||
client = GiteaWorkflowClient("https://git.example.com", "tok")
|
||||
err = urllib.error.HTTPError(
|
||||
"https://git.example.com/api/v1/test",
|
||||
500,
|
||||
"Internal Server Error",
|
||||
{},
|
||||
__import__("io").BytesIO(b"plain text error"),
|
||||
)
|
||||
with patch("urllib.request.urlopen", side_effect=err):
|
||||
with pytest.raises(GiteaAPIError) as exc_info:
|
||||
client._request("GET", "/test")
|
||||
assert exc_info.value.status == 500
|
||||
assert "plain text error" in exc_info.value.message
|
||||
@@ -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."""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user