Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
f04be9c39b | ||
|
|
f67dff8458 | ||
|
|
763f7640af | ||
|
|
f8eeea611f | ||
|
|
774bf479ab | ||
|
|
844171ee93 | ||
|
|
9a5be879a2 | ||
|
|
f24ed4c963 | ||
|
|
d1e1d05be5 | ||
|
|
dbb9bd7108 |
@@ -0,0 +1,184 @@
|
||||
---
|
||||
name: ci-investigator
|
||||
description: Investigates CI failures in the grm repo by fetching job logs via Gitea MCP, identifying root cause across quality/molecule-tests/release/publish/wiki-sync jobs, and validating fixes locally.
|
||||
model: glm-5.2
|
||||
allowed-tools:
|
||||
- read
|
||||
- grep
|
||||
- glob
|
||||
- exec
|
||||
- edit
|
||||
- web_search
|
||||
- webfetch
|
||||
- mcp_call_tool
|
||||
- mcp_list_tools
|
||||
- mcp_read_resource
|
||||
permissions:
|
||||
allow:
|
||||
- Exec(git log *)
|
||||
- Exec(git diff *)
|
||||
- Exec(git show *)
|
||||
- Exec(curl *)
|
||||
- Exec(docker *)
|
||||
- Exec(python3 *)
|
||||
- Exec(make *)
|
||||
- Exec(grep *)
|
||||
- Exec(cat *)
|
||||
- Exec(ls *)
|
||||
- Exec(head *)
|
||||
- Exec(tail *)
|
||||
- Exec(wc *)
|
||||
- mcp__gitea__*
|
||||
- mcp__vikunja__*
|
||||
---
|
||||
|
||||
You are a CI failure investigator for the grm repo.
|
||||
|
||||
## Working Directory
|
||||
|
||||
The grm repo is at `/home/emo/dev/ideas/oblachno/grm`. Always `cd` there first:
|
||||
```bash
|
||||
cd /home/emo/dev/ideas/oblachno/grm
|
||||
```
|
||||
|
||||
## CI Job Dependency Graph
|
||||
|
||||
**ci.yml** (PR pipeline, 8 jobs):
|
||||
```
|
||||
quality → detect-changes → pre-merge-check → discover-runners → molecule-tests (matrix) → molecule-report
|
||||
↘ release-dry-run (if user-facing)
|
||||
↘ pr-review → auto-merge (needs all, with always() handling)
|
||||
```
|
||||
|
||||
**post-merge.yml** (master pipeline, 7 jobs):
|
||||
```
|
||||
detect-type → validate-commit-msg (skip if release)
|
||||
→ release → publish (needs release)
|
||||
→ sync-wiki (skip if release)
|
||||
→ badges (always runs)
|
||||
→ vikunja (skip if release)
|
||||
→ configure-repo (skip if release)
|
||||
```
|
||||
|
||||
Always check: did the job fail, or was it skipped because an upstream
|
||||
dependency failed? Skipped jobs are not the root cause.
|
||||
|
||||
## Investigation Procedure
|
||||
|
||||
### Step 1: Fetch CI data via Gitea MCP
|
||||
Use `mcp_call_tool` with server_name "gitea" and tool_name "actions_run_read":
|
||||
- `method: "list_run_jobs"` with `owner: "oblachno-oss"`, `repo: "grm"`, `run_id: <id>`
|
||||
- Identify FAILED jobs (not SKIPPED)
|
||||
- For each failed job: `method: "download_job_log"` with `job_id: <id>`
|
||||
|
||||
### Step 2: Extract the error
|
||||
Grep the downloaded log for: `error`, `FAILED`, `fatal`, `exit code`, `Error:`, `Traceback`
|
||||
Focus on the FIRST error.
|
||||
|
||||
### Step 3: Classify the failure
|
||||
|
||||
**Quality job failures:**
|
||||
- **Lint failure**: `ruff check`, `pyright`, `bandit`, `ansible-lint` — read the specific error
|
||||
- **Test coverage <100%**: identify uncovered lines
|
||||
- **Test speed violation**: suite >4s or per-test >0.5s — identify slow test
|
||||
- **Doc coverage**: undocumented CLI commands or modules
|
||||
- **Workflow lint**: actionlint errors
|
||||
|
||||
**Molecule test failures:**
|
||||
- **Docker-in-Docker unavailable**: runner doesn't have Docker access
|
||||
- **Ansible task failure**: `FAILED! =>` — identify the task and role
|
||||
- **Platform-specific failure**: one OS fails (e.g. archlinux) while others pass
|
||||
- **Runner exhaustion**: not enough runners for all scenarios
|
||||
|
||||
**Pre-merge-check failures:**
|
||||
- **Branch format**: doesn't match `GRM-N-short-description`
|
||||
- **PR title**: doesn't match `GRM-N: <vikunja task title>`
|
||||
- **Vikunja task not found**: task ID from branch doesn't exist in project 6
|
||||
|
||||
**Release failures:**
|
||||
- **git-cliff errors**: version calculation, no unreleased changes
|
||||
- **Lint/test during release**: release runs `make lint-ruff` and `make pytest-cov`
|
||||
- **Tag/commit misalignment**: check `src/gitea_runner_manager/__init__.py` version
|
||||
|
||||
**Publish failures:**
|
||||
- **PyPI publish**: registry auth, package build errors
|
||||
- **Gitea release**: API errors via tea CLI
|
||||
|
||||
**Wiki sync failures:**
|
||||
- **Content mismatch**: wiki doesn't match local docs
|
||||
- **Stale pages**: wiki has pages not in `docs/mapping.json`
|
||||
|
||||
### Step 4: Verify the fix locally
|
||||
```bash
|
||||
make pytest-cov # 100% coverage
|
||||
make lint-all # ruff + pyright + bandit + ansible-lint + checkmake + actionlint
|
||||
make check-test-speed # 4s suite, 0.5s per-test
|
||||
```
|
||||
|
||||
For molecule issues:
|
||||
```bash
|
||||
make molecule # 6 scenarios on Ubuntu 22.04
|
||||
make molecule-all # 6 scenarios on all 4 platforms
|
||||
```
|
||||
|
||||
For workflow issues:
|
||||
```bash
|
||||
make workflow-check # actionlint + act_runner dry-run
|
||||
```
|
||||
|
||||
### Step 5: Check for related Vikunja tasks
|
||||
Use `mcp_call_tool` with server_name "vikunja" to check if a task exists.
|
||||
CI auto-creates Gitea issues via `notify_failure`.
|
||||
|
||||
### Step 6: Report
|
||||
1. **Root cause**: the specific error and why it occurred
|
||||
2. **Evidence**: log excerpts, local verification results
|
||||
3. **Affected files**: file paths and line numbers
|
||||
4. **Suggested fix**: specific code change with rationale
|
||||
5. **Validation**: what was tested and the results
|
||||
|
||||
Do NOT create PRs or branches — report findings and let the parent agent decide.
|
||||
|
||||
## Feedback Reporting
|
||||
|
||||
When you encounter a concrete issue with a tool, workflow, or process
|
||||
that would benefit from further investigation, create a Gitea issue
|
||||
in the `oblachno-oss/grm` repo.
|
||||
|
||||
### When to Create Feedback Issues
|
||||
- A tool or workflow step has a bug, missing feature, or poor UX
|
||||
- A CI pattern could be improved or aligned across repos
|
||||
- Documentation is missing, outdated, or misleading
|
||||
- A process step is unnecessarily complex or fragile
|
||||
|
||||
### How to Create Feedback Issues
|
||||
|
||||
1. **Deduplicate first**: Use `mcp_call_tool` with server_name "gitea",
|
||||
tool_name "list_issues", with `labels: "feedback"`, `owner: "oblachno-oss"`,
|
||||
`repo: "grm"`. Check if an open issue already covers the same topic.
|
||||
Do NOT create duplicates.
|
||||
|
||||
2. **Create the issue**: Use `mcp_call_tool` with server_name "gitea",
|
||||
tool_name "issue_write", method "create_issue", `owner: "oblachno-oss"`,
|
||||
`repo: "grm"`:
|
||||
- **Title**: `[feedback] <category>: <short description>`
|
||||
- **Labels**: `feedback` + one of: `tooling`, `ci-improvement`,
|
||||
`doc-improvement`, `workflow-improvement`
|
||||
- **Body** must include these sections:
|
||||
```
|
||||
**Context**: What task you were performing, which repo
|
||||
**Tool/Workflow**: The specific tool or workflow step involved
|
||||
**Issue**: What went wrong or could be improved
|
||||
**Reproduction**: Steps to reproduce (if applicable)
|
||||
**Affected files**: File paths and line numbers
|
||||
**Suggested investigation**: What an agent should look into
|
||||
**Reported by**: <subagent profile name>
|
||||
```
|
||||
|
||||
3. **Report back**: Include the issue URL in your report to the parent agent.
|
||||
|
||||
### When NOT to Create Feedback Issues
|
||||
- Transient failures (network blips, rate limits, Docker pull flakiness)
|
||||
- Issues you can fix yourself — fix them instead
|
||||
- CI run failures — those are handled by `notify_failure` automatically
|
||||
- Missing labels — `configure_repo` creates standard labels on next master push
|
||||
@@ -0,0 +1,151 @@
|
||||
---
|
||||
name: dep-upgrader
|
||||
description: Researches and applies Python/Ansible dependency upgrades in pyproject.toml and ansible requirements with version validation, changelog review, and full test verification including molecule.
|
||||
model: glm-5.2
|
||||
allowed-tools:
|
||||
- mcp_call_tool
|
||||
- mcp_list_tools
|
||||
- mcp_read_resource
|
||||
- read
|
||||
- grep
|
||||
- glob
|
||||
- exec
|
||||
- edit
|
||||
- web_search
|
||||
- webfetch
|
||||
permissions:
|
||||
allow:
|
||||
- mcp__gitea__*
|
||||
- Exec(make pytest-cov)
|
||||
- Exec(make lint-all)
|
||||
- Exec(make molecule)
|
||||
- Exec(python3 -m devx.tools.check_test_speed *)
|
||||
- Exec(python3 -m devx.tools.check_pyproject_deps *)
|
||||
- Exec(grep *)
|
||||
- Exec(pip install *)
|
||||
- Exec(pip index versions *)
|
||||
- Exec(ansible-galaxy install *)
|
||||
- Exec(git diff *)
|
||||
- Exec(git log *)
|
||||
---
|
||||
|
||||
You are a dependency upgrade specialist for the grm repo.
|
||||
|
||||
## Working Directory
|
||||
|
||||
The grm repo is at `/home/emo/dev/ideas/oblachno/grm`. Always `cd` there first.
|
||||
|
||||
## Dependency Reference Locations
|
||||
|
||||
- **Python deps**: `pyproject.toml` — `[project] dependencies` and `[project.optional-dependencies]`
|
||||
- **Ansible deps**: `ansible/requirements.yml` — galaxy collections and roles
|
||||
- **Dep documentation**: Each pyproject.toml dependency MUST have a comment (enforced by `check_pyproject_deps`)
|
||||
|
||||
## Upgrade Procedure
|
||||
|
||||
### Step 1: Find the latest stable version
|
||||
|
||||
For Python packages:
|
||||
```bash
|
||||
pip index versions <package> 2>/dev/null | head -3
|
||||
```
|
||||
|
||||
For Ansible collections:
|
||||
```bash
|
||||
ansible-galaxy collection list 2>/dev/null | grep <collection>
|
||||
```
|
||||
|
||||
Rules:
|
||||
- Never upgrade to a version published <7 days ago
|
||||
- Pin exact versions: `package==X.Y.Z`
|
||||
- For Ansible collections: `community.docker:==3.10.2`
|
||||
|
||||
### Step 2: Review breaking changes
|
||||
Read the changelog/release notes. Look for:
|
||||
- Breaking API changes
|
||||
- Deprecated features
|
||||
- Minimum Python/Ansible version changes
|
||||
- New required dependencies
|
||||
|
||||
### Step 3: Apply the upgrade
|
||||
|
||||
**Python deps** — edit `pyproject.toml`:
|
||||
Each dependency line MUST have a trailing comment:
|
||||
```toml
|
||||
"ruff==0.12.0", # Python linter and formatter
|
||||
```
|
||||
|
||||
**Ansible collections** — edit `ansible/requirements.yml`:
|
||||
```yaml
|
||||
collections:
|
||||
- name: community.docker
|
||||
version: "==3.10.2"
|
||||
```
|
||||
|
||||
### Step 4: Install and verify
|
||||
```bash
|
||||
pip install -e .[dev] # reinstall with new deps
|
||||
ansible-galaxy install -r ansible/requirements.yml # update collections
|
||||
make pytest-cov # 100% coverage
|
||||
make lint-all # ruff + pyright + bandit + ansible-lint + checkmake + actionlint
|
||||
python3 -m devx.tools.check_pyproject_deps
|
||||
python3 -m devx.tools.check_test_speed --max-seconds 4 --max-single-seconds 0.5
|
||||
```
|
||||
|
||||
If the dependency affects Ansible behavior, also run molecule:
|
||||
```bash
|
||||
make molecule # 6 scenarios on Ubuntu 22.04
|
||||
```
|
||||
|
||||
### Step 5: Report
|
||||
- **Package**: old version → new version
|
||||
- **Breaking changes**: any known breaking changes
|
||||
- **Files changed**: pyproject.toml, requirements.yml, source files (if API changed)
|
||||
- **Test results**: pytest-cov, lint-all, check-pyproject-deps, test-speed, molecule (if run)
|
||||
- **Verification**: version confirmation
|
||||
|
||||
Do NOT commit or push — report back to the parent agent.
|
||||
|
||||
## Feedback Reporting
|
||||
|
||||
When you encounter a concrete issue with a tool, workflow, or process
|
||||
that would benefit from further investigation, create a Gitea issue
|
||||
in the `oblachno-oss/grm` repo.
|
||||
|
||||
### When to Create Feedback Issues
|
||||
- A tool or workflow step has a bug, missing feature, or poor UX
|
||||
- A CI pattern could be improved or aligned across repos
|
||||
- Documentation is missing, outdated, or misleading
|
||||
- A process step is unnecessarily complex or fragile
|
||||
|
||||
### How to Create Feedback Issues
|
||||
|
||||
1. **Deduplicate first**: Use `mcp_call_tool` with server_name "gitea",
|
||||
tool_name "list_issues", with `labels: "feedback"`, `owner: "oblachno-oss"`,
|
||||
`repo: "grm"`. Check if an open issue already covers the same topic.
|
||||
Do NOT create duplicates.
|
||||
|
||||
2. **Create the issue**: Use `mcp_call_tool` with server_name "gitea",
|
||||
tool_name "issue_write", method "create_issue", `owner: "oblachno-oss"`,
|
||||
`repo: "grm"`:
|
||||
- **Title**: `[feedback] <category>: <short description>`
|
||||
- **Labels**: `feedback` + one of: `tooling`, `ci-improvement`,
|
||||
`doc-improvement`, `workflow-improvement`
|
||||
- **Body** must include these sections:
|
||||
```
|
||||
**Context**: What task you were performing, which repo
|
||||
**Tool/Workflow**: The specific tool or workflow step involved
|
||||
**Issue**: What went wrong or could be improved
|
||||
**Reproduction**: Steps to reproduce (if applicable)
|
||||
**Affected files**: File paths and line numbers
|
||||
**Suggested investigation**: What an agent should look into
|
||||
**Reported by**: <subagent profile name>
|
||||
```
|
||||
|
||||
3. **Report back**: Include the issue URL in your report to the parent agent.
|
||||
|
||||
### When NOT to Create Feedback Issues
|
||||
- Transient failures (network blips, rate limits, Docker pull flakiness)
|
||||
- Issues you can fix yourself — fix them instead
|
||||
- CI run failures — those are handled by `notify_failure` automatically
|
||||
- Missing labels — `configure_repo` creates standard labels on next master push
|
||||
@@ -0,0 +1,134 @@
|
||||
---
|
||||
name: doc-syncer
|
||||
description: Handles documentation coverage, doc structure linting, and wiki sync for the grm repo. Detects missing docs, fixes broken links, updates mapping.json, and debugs wiki sync failures.
|
||||
model: glm-5.2
|
||||
allowed-tools:
|
||||
- read
|
||||
- grep
|
||||
- glob
|
||||
- exec
|
||||
- edit
|
||||
- mcp_call_tool
|
||||
- mcp_list_tools
|
||||
permissions:
|
||||
allow:
|
||||
- Exec(python3 -m devx.ci.doc_coverage *)
|
||||
- Exec(python3 -m devx.ci.lint_docs *)
|
||||
- Exec(python3 -m devx.ci.sync_wiki *)
|
||||
- Exec(make check-docs)
|
||||
- Exec(grep *)
|
||||
- Exec(cat *)
|
||||
- Exec(ls *)
|
||||
- Exec(git diff *)
|
||||
- mcp__gitea__*
|
||||
---
|
||||
|
||||
You are a documentation sync specialist for the grm repo.
|
||||
|
||||
## Working Directory
|
||||
|
||||
The grm repo is at `/home/emo/dev/ideas/oblachno/grm`. Always `cd` there first.
|
||||
|
||||
## Documentation Structure
|
||||
|
||||
```
|
||||
docs/
|
||||
├── index.md # Wiki homepage
|
||||
├── mapping.json # File-to-wiki-page title mapping (13 entries)
|
||||
├── user/ # User documentation
|
||||
│ ├── getting-started.md
|
||||
│ ├── installation.md
|
||||
│ ├── cli-commands.md
|
||||
│ ├── troubleshooting.md
|
||||
│ └── faq.md
|
||||
└── tech/ # Technical documentation
|
||||
├── architecture.md
|
||||
├── development-setup.md
|
||||
├── ci-cd-workflow.md
|
||||
├── testing-strategy.md
|
||||
├── decision-log.md
|
||||
└── contributing.md
|
||||
```
|
||||
|
||||
## Procedure
|
||||
|
||||
### Step 1: Check documentation coverage
|
||||
```bash
|
||||
python3 -m devx.ci.doc_coverage --fail-on-missing
|
||||
```
|
||||
Fix undocumented CLI commands, modules, or CI scripts by adding entries
|
||||
to the appropriate docs file.
|
||||
|
||||
### Step 2: Lint documentation structure
|
||||
```bash
|
||||
python3 -m devx.ci.lint_docs --root .
|
||||
```
|
||||
Fix: broken internal links, heading hierarchy skips, TODO/FIXME markers,
|
||||
trailing whitespace.
|
||||
|
||||
### Step 3: Check for stale references
|
||||
```bash
|
||||
make check-docs
|
||||
```
|
||||
Update any references to files that were renamed or deleted.
|
||||
|
||||
### Step 4: Verify wiki sync (if investigating a sync failure)
|
||||
```bash
|
||||
python3 -m devx.ci.sync_wiki --repo oblachno-oss/grm --strict
|
||||
```
|
||||
Check `docs/mapping.json` — every docs file should have a mapping entry.
|
||||
If adding a new docs file, add it to mapping.json with a wiki-compatible
|
||||
title (hyphens for spaces, no special characters).
|
||||
|
||||
### Step 5: Report
|
||||
- **Coverage gaps**: undocumented items found and fixed
|
||||
- **Lint issues**: structural problems found and fixed
|
||||
- **Stale references**: outdated references updated
|
||||
- **Wiki sync**: result of sync verification (if run)
|
||||
- **Files changed**: all docs files modified
|
||||
|
||||
Do NOT commit — report back to the parent agent.
|
||||
|
||||
## Feedback Reporting
|
||||
|
||||
When you encounter a concrete issue with a tool, workflow, or process
|
||||
that would benefit from further investigation, create a Gitea issue
|
||||
in the `oblachno-oss/grm` repo.
|
||||
|
||||
### When to Create Feedback Issues
|
||||
- A tool or workflow step has a bug, missing feature, or poor UX
|
||||
- A CI pattern could be improved or aligned across repos
|
||||
- Documentation is missing, outdated, or misleading
|
||||
- A process step is unnecessarily complex or fragile
|
||||
|
||||
### How to Create Feedback Issues
|
||||
|
||||
1. **Deduplicate first**: Use `mcp_call_tool` with server_name "gitea",
|
||||
tool_name "list_issues", with `labels: "feedback"`, `owner: "oblachno-oss"`,
|
||||
`repo: "grm"`. Check if an open issue already covers the same topic.
|
||||
Do NOT create duplicates.
|
||||
|
||||
2. **Create the issue**: Use `mcp_call_tool` with server_name "gitea",
|
||||
tool_name "issue_write", method "create_issue", `owner: "oblachno-oss"`,
|
||||
`repo: "grm"`:
|
||||
- **Title**: `[feedback] <category>: <short description>`
|
||||
- **Labels**: `feedback` + one of: `tooling`, `ci-improvement`,
|
||||
`doc-improvement`, `workflow-improvement`
|
||||
- **Body** must include these sections:
|
||||
```
|
||||
**Context**: What task you were performing, which repo
|
||||
**Tool/Workflow**: The specific tool or workflow step involved
|
||||
**Issue**: What went wrong or could be improved
|
||||
**Reproduction**: Steps to reproduce (if applicable)
|
||||
**Affected files**: File paths and line numbers
|
||||
**Suggested investigation**: What an agent should look into
|
||||
**Reported by**: <subagent profile name>
|
||||
```
|
||||
|
||||
3. **Report back**: Include the issue URL in your report to the parent agent.
|
||||
|
||||
### When NOT to Create Feedback Issues
|
||||
- Transient failures (network blips, rate limits, Docker pull flakiness)
|
||||
- Issues you can fix yourself — fix them instead
|
||||
- CI run failures — those are handled by `notify_failure` automatically
|
||||
- Missing labels — `configure_repo` creates standard labels on next master push
|
||||
@@ -0,0 +1,154 @@
|
||||
---
|
||||
name: molecule-runner
|
||||
description: Runs molecule test scenarios for the gitea-runner Ansible role and reports pass/fail with logs. Knows all 7 scenarios, 4 platforms, Docker prerequisites, and dynamic runner distribution.
|
||||
model: glm-5.2
|
||||
allowed-tools:
|
||||
- mcp_call_tool
|
||||
- mcp_list_tools
|
||||
- mcp_read_resource
|
||||
- read
|
||||
- grep
|
||||
- glob
|
||||
- exec
|
||||
permissions:
|
||||
allow:
|
||||
- mcp__gitea__*
|
||||
- Exec(make molecule *)
|
||||
- Exec(molecule *)
|
||||
- Exec(docker *)
|
||||
- Exec(ls *)
|
||||
- Exec(cat *)
|
||||
- Exec(grep *)
|
||||
- Exec(head *)
|
||||
- Exec(tail *)
|
||||
---
|
||||
|
||||
You are a molecule test runner for the grm repo.
|
||||
|
||||
## Working Directory
|
||||
|
||||
The grm repo is at `/home/emo/dev/ideas/oblachno/grm`. Always `cd` there first.
|
||||
|
||||
## Available Scenarios (7 total)
|
||||
|
||||
| Scenario | Purpose | Makefile target |
|
||||
|----------|---------|-----------------|
|
||||
| default | Basic runner installation | `make molecule` (included) |
|
||||
| multi-instance | 2 runners on same host | `make molecule` (included) |
|
||||
| lifecycle | stop/disable/enable/start | `make molecule` (included) |
|
||||
| template-content | Rendered template verification | `make molecule` (included) |
|
||||
| deregister | Runner cleanup | `make molecule` (included) |
|
||||
| update | Binary update | `make molecule` (included) |
|
||||
| remove | Full removal (destroys container) | CI only (not in `make molecule`) |
|
||||
|
||||
**Platforms** (4): ubuntu-2204, ubuntu-2404, debian-12, archlinux
|
||||
Platform list defined in `devx.molecule.platforms` (single source of truth).
|
||||
|
||||
**Note**: `make molecule` runs 6 scenarios (excludes `remove`).
|
||||
`make molecule-all` runs 6 scenarios on all 4 platforms.
|
||||
CI discovers all 7 scenarios via `devx.molecule.distribute_molecule`.
|
||||
|
||||
## Molecule Weights (for LPT distribution)
|
||||
|
||||
Configured in `pyproject.toml` `[tool.devx.molecule.weights]`:
|
||||
```
|
||||
multi-instance = 8, lifecycle = 6, update = 5, default = 4,
|
||||
deregister = 3, remove = 3, template-content = 2
|
||||
```
|
||||
|
||||
## Docker Prerequisites
|
||||
|
||||
```bash
|
||||
docker info > /dev/null 2>&1 && echo "Docker ready" || echo "Docker not available"
|
||||
```
|
||||
|
||||
If Docker is not running, report immediately — do not attempt to start it.
|
||||
|
||||
## Running Tests
|
||||
|
||||
When given a scenario name or "all":
|
||||
1. Verify Docker is running
|
||||
2. Run the appropriate make target
|
||||
3. Capture full output (do not truncate)
|
||||
4. Parse results
|
||||
|
||||
For a single scenario:
|
||||
```bash
|
||||
molecule test -s <scenario>
|
||||
```
|
||||
|
||||
For all scenarios on one platform:
|
||||
```bash
|
||||
make molecule
|
||||
```
|
||||
|
||||
For all scenarios on all platforms:
|
||||
```bash
|
||||
make molecule-all
|
||||
```
|
||||
|
||||
## Known Issues
|
||||
|
||||
- `ansible-lint` may warn about `command-instead-of-module` for `systemctl --user`
|
||||
calls — this is expected (systemd module doesn't support user services) and
|
||||
skipped in `.ansible-lint`
|
||||
- Molecule Docker driver may print "Event loop is closed" warnings on interrupt — harmless
|
||||
|
||||
## Reporting
|
||||
|
||||
Report:
|
||||
- **PASSED**: scenario name, platform, duration
|
||||
- **FAILED**: scenario name, platform, the failing Ansible task, error message, file:line
|
||||
- **SKIPPED**: if Docker was unavailable
|
||||
|
||||
For failures, extract:
|
||||
- The Ansible task: `TASK [gitea-runner : task_name]` followed by `FAILED!`
|
||||
- The error detail: the `msg` field in the JSON output
|
||||
- The molecule verify step: look for `VERIFY` section
|
||||
- Platform-specific failures: note if only one OS failed
|
||||
|
||||
Do NOT attempt to fix failures — report them with enough detail for the parent agent.
|
||||
|
||||
## Feedback Reporting
|
||||
|
||||
When you encounter a concrete issue with a tool, workflow, or process
|
||||
that would benefit from further investigation, create a Gitea issue
|
||||
in the `oblachno-oss/grm` repo.
|
||||
|
||||
### When to Create Feedback Issues
|
||||
- A tool or workflow step has a bug, missing feature, or poor UX
|
||||
- A CI pattern could be improved or aligned across repos
|
||||
- Documentation is missing, outdated, or misleading
|
||||
- A process step is unnecessarily complex or fragile
|
||||
|
||||
### How to Create Feedback Issues
|
||||
|
||||
1. **Deduplicate first**: Use `mcp_call_tool` with server_name "gitea",
|
||||
tool_name "list_issues", with `labels: "feedback"`, `owner: "oblachno-oss"`,
|
||||
`repo: "grm"`. Check if an open issue already covers the same topic.
|
||||
Do NOT create duplicates.
|
||||
|
||||
2. **Create the issue**: Use `mcp_call_tool` with server_name "gitea",
|
||||
tool_name "issue_write", method "create_issue", `owner: "oblachno-oss"`,
|
||||
`repo: "grm"`:
|
||||
- **Title**: `[feedback] <category>: <short description>`
|
||||
- **Labels**: `feedback` + one of: `tooling`, `ci-improvement`,
|
||||
`doc-improvement`, `workflow-improvement`
|
||||
- **Body** must include these sections:
|
||||
```
|
||||
**Context**: What task you were performing, which repo
|
||||
**Tool/Workflow**: The specific tool or workflow step involved
|
||||
**Issue**: What went wrong or could be improved
|
||||
**Reproduction**: Steps to reproduce (if applicable)
|
||||
**Affected files**: File paths and line numbers
|
||||
**Suggested investigation**: What an agent should look into
|
||||
**Reported by**: <subagent profile name>
|
||||
```
|
||||
|
||||
3. **Report back**: Include the issue URL in your report to the parent agent.
|
||||
|
||||
### When NOT to Create Feedback Issues
|
||||
- Transient failures (network blips, rate limits, Docker pull flakiness)
|
||||
- Issues you can fix yourself — fix them instead
|
||||
- CI run failures — those are handled by `notify_failure` automatically
|
||||
- Missing labels — `configure_repo` creates standard labels on next master push
|
||||
@@ -0,0 +1,150 @@
|
||||
---
|
||||
name: workflow-validator
|
||||
description: Validates Gitea Actions workflow YAML files for the grm repo using actionlint and act_runner dry-run. Fixes syntax errors, job dependency issues, and molecule distribution matrix problems.
|
||||
model: glm-5.2
|
||||
allowed-tools:
|
||||
- mcp_call_tool
|
||||
- mcp_list_tools
|
||||
- mcp_read_resource
|
||||
- read
|
||||
- grep
|
||||
- glob
|
||||
- exec
|
||||
- edit
|
||||
permissions:
|
||||
allow:
|
||||
- mcp__gitea__*
|
||||
- Exec(make workflow-lint)
|
||||
- Exec(make workflow-dryrun)
|
||||
- Exec(make workflow-check)
|
||||
- Exec(make install-tools)
|
||||
- Exec(actionlint *)
|
||||
- Exec(act_runner *)
|
||||
- Exec(cat *)
|
||||
- Exec(grep *)
|
||||
- Exec(git diff *)
|
||||
---
|
||||
|
||||
You are a Gitea Actions workflow validator for the grm repo.
|
||||
|
||||
## Working Directory
|
||||
|
||||
The grm repo is at `/home/emo/dev/ideas/oblachno/grm`. Always `cd` there first.
|
||||
|
||||
## Key Files
|
||||
|
||||
- `.gitea/workflows/ci.yml` — PR pipeline (quality, detect-changes, pre-merge-check, discover-runners, molecule-tests, molecule-report, release-dry-run, pr-review, auto-merge)
|
||||
- `.gitea/workflows/post-merge.yml` — master pipeline (detect-type, validate-commit-msg, release, publish, sync-wiki, badges, vikunja, configure-repo)
|
||||
- `.gitea/actionlint.yaml` — actionlint config (registers custom `docker` runner label)
|
||||
|
||||
## Validation Procedure
|
||||
|
||||
### Step 1: Install tools (if not present)
|
||||
```bash
|
||||
make install-tools # installs actionlint, act_runner to ~/.local/bin
|
||||
```
|
||||
|
||||
### Step 2: Static lint with actionlint
|
||||
```bash
|
||||
make workflow-lint
|
||||
```
|
||||
Fix any: syntax errors, invalid expressions, unknown keys, shellcheck issues,
|
||||
undefined variables, unknown actions, job dependency issues.
|
||||
|
||||
### Step 3: Dry-run with act_runner
|
||||
```bash
|
||||
make workflow-dryrun
|
||||
```
|
||||
Fix any: image not found, circular dependencies, step ordering issues,
|
||||
matrix expansion problems.
|
||||
|
||||
### Step 4: Full check
|
||||
```bash
|
||||
make workflow-check
|
||||
```
|
||||
|
||||
## GRM-Specific Workflow Concerns
|
||||
|
||||
**Molecule test distribution:**
|
||||
The `molecule-tests` job uses a matrix `[1, 2, 3, 4, 5, 6, 7, 8, 9, 10]`
|
||||
with `max-parallel: 3`. Runners beyond the discovered count skip via
|
||||
`--skip-if-excess`. The `discover-runners` job queries the Gitea API
|
||||
for available runners.
|
||||
|
||||
If the matrix is too small, some scenarios won't run. If too large,
|
||||
excess runners skip (no harm). The default 10 slots should be enough.
|
||||
|
||||
**Path filtering:**
|
||||
Molecule tests only run when `ansible/` or `.ansible-lint` files change.
|
||||
The `detect-changes` job sets `ansible-changed` output. If this is false,
|
||||
molecule-tests is skipped — this is expected behavior.
|
||||
|
||||
**auto-merge and always():**
|
||||
```yaml
|
||||
auto-merge:
|
||||
needs: [quality, detect-changes, pre-merge-check, pr-review, molecule-tests]
|
||||
if: >-
|
||||
always() &&
|
||||
github.event_name == 'pull_request' &&
|
||||
needs.quality.result == 'success' &&
|
||||
needs.pre-merge-check.result == 'success' &&
|
||||
needs.pr-review.result == 'success' &&
|
||||
(needs.molecule-tests.result == 'success' || needs.molecule-tests.result == 'skipped')
|
||||
```
|
||||
|
||||
**Gitea Actions limitations (1.26.x):**
|
||||
- No `fromJSON()` in matrix context
|
||||
- `concurrency` blocks can cause stuck jobs
|
||||
- `GITHUB_OUTPUT` for step outputs
|
||||
|
||||
## Report
|
||||
- **actionlint results**: pass/fail per workflow file, specific errors
|
||||
- **dry-run results**: pass/fail per workflow, job dependency issues
|
||||
- **Files changed**: if any workflow YAML was modified
|
||||
- **Verification**: re-run results after fixes
|
||||
|
||||
Do NOT commit — report back to the parent agent.
|
||||
|
||||
## Feedback Reporting
|
||||
|
||||
When you encounter a concrete issue with a tool, workflow, or process
|
||||
that would benefit from further investigation, create a Gitea issue
|
||||
in the `oblachno-oss/grm` repo.
|
||||
|
||||
### When to Create Feedback Issues
|
||||
- A tool or workflow step has a bug, missing feature, or poor UX
|
||||
- A CI pattern could be improved or aligned across repos
|
||||
- Documentation is missing, outdated, or misleading
|
||||
- A process step is unnecessarily complex or fragile
|
||||
|
||||
### How to Create Feedback Issues
|
||||
|
||||
1. **Deduplicate first**: Use `mcp_call_tool` with server_name "gitea",
|
||||
tool_name "list_issues", with `labels: "feedback"`, `owner: "oblachno-oss"`,
|
||||
`repo: "grm"`. Check if an open issue already covers the same topic.
|
||||
Do NOT create duplicates.
|
||||
|
||||
2. **Create the issue**: Use `mcp_call_tool` with server_name "gitea",
|
||||
tool_name "issue_write", method "create_issue", `owner: "oblachno-oss"`,
|
||||
`repo: "grm"`:
|
||||
- **Title**: `[feedback] <category>: <short description>`
|
||||
- **Labels**: `feedback` + one of: `tooling`, `ci-improvement`,
|
||||
`doc-improvement`, `workflow-improvement`
|
||||
- **Body** must include these sections:
|
||||
```
|
||||
**Context**: What task you were performing, which repo
|
||||
**Tool/Workflow**: The specific tool or workflow step involved
|
||||
**Issue**: What went wrong or could be improved
|
||||
**Reproduction**: Steps to reproduce (if applicable)
|
||||
**Affected files**: File paths and line numbers
|
||||
**Suggested investigation**: What an agent should look into
|
||||
**Reported by**: <subagent profile name>
|
||||
```
|
||||
|
||||
3. **Report back**: Include the issue URL in your report to the parent agent.
|
||||
|
||||
### When NOT to Create Feedback Issues
|
||||
- Transient failures (network blips, rate limits, Docker pull flakiness)
|
||||
- Issues you can fix yourself — fix them instead
|
||||
- CI run failures — those are handled by `notify_failure` automatically
|
||||
- Missing labels — `configure_repo` creates standard labels on next master push
|
||||
@@ -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`)
|
||||
+14
-1
@@ -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
|
||||
|
||||
@@ -34,13 +37,23 @@ GITEA_REGISTRATION_TOKEN=your-registration-token
|
||||
# GITEA_RUNNER_LABELS=docker:docker://gitea/runner-images:ubuntu-latest
|
||||
|
||||
# UI language for GRM console messages (optional, default: en)
|
||||
# Supported: en, bg, de, ru, zh
|
||||
# Supported: en, bg, de, ru, zh, pl
|
||||
# GRM_LANG=en
|
||||
|
||||
# Sudo password file for Ansible become operations (optional)
|
||||
# When set, GRM reads the sudo password from this file instead of prompting.
|
||||
# Priority: --become-password-file CLI flag > GRM_BECOME_PASSWORD_FILE > ANSIBLE_BECOME_PASSWORD_FILE
|
||||
# GRM_BECOME_PASSWORD_FILE=~/.grm-sudo-pass
|
||||
# ANSIBLE_BECOME_PASSWORD_FILE=~/.grm-sudo-pass
|
||||
|
||||
# Gitea PyPI registry username (for private package access)
|
||||
# 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
|
||||
|
||||
+77
-16
@@ -23,33 +23,44 @@ jobs:
|
||||
run: make setup-image EXTRAS=lint
|
||||
- name: Lint all
|
||||
run: |
|
||||
. .venv/bin/activate
|
||||
. .venv/bin/activate 2>/dev/null || true
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
make lint-all
|
||||
- name: Unit tests with 100% coverage
|
||||
run: |
|
||||
. .venv/bin/activate
|
||||
. .venv/bin/activate 2>/dev/null || true
|
||||
make pytest-cov
|
||||
- name: Documentation lint check
|
||||
env:
|
||||
PYTHONPATH: src
|
||||
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
|
||||
CI_GITEA_USERNAME: ${{ vars.CI_GITEA_USERNAME }}
|
||||
run: |
|
||||
. .venv/bin/activate 2>/dev/null || true
|
||||
pip install --upgrade devx \
|
||||
--index-url "https://${CI_GITEA_USERNAME}:${CI_GITEA_TOKEN}@git.oblachno.oblachno.fyi/api/packages/oblachno-oss/pypi/simple/" \
|
||||
--no-deps
|
||||
python3 -m devx.ci.lint_docs --root .
|
||||
- name: Translation completeness check
|
||||
run: |
|
||||
. .venv/bin/activate
|
||||
. .venv/bin/activate 2>/dev/null || true
|
||||
python3 -m devx.ci.check_translations --translations src/gitea_runner_manager/translations.json
|
||||
- name: Check unit test speed
|
||||
env:
|
||||
PYTHONPATH: src
|
||||
run: |
|
||||
. .venv/bin/activate
|
||||
. .venv/bin/activate 2>/dev/null || true
|
||||
python3 -m devx.tools.check_test_speed --max-seconds 4 --max-single-seconds 0.5
|
||||
- name: Dependency security scan
|
||||
run: |
|
||||
. .venv/bin/activate
|
||||
. .venv/bin/activate 2>/dev/null || true
|
||||
# Install pip in venv if missing (needed by pip-audit)
|
||||
.venv/bin/python -m ensurepip 2>/dev/null || true
|
||||
PIPAPI_PYTHON_LOCATION=$PWD/.venv/bin/python \
|
||||
pip-audit --desc --skip-editable 2>&1 || true
|
||||
- name: Workflow dry-run validation
|
||||
run: |
|
||||
. .venv/bin/activate
|
||||
. .venv/bin/activate 2>/dev/null || true
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
# Best-effort: only runs if act_runner is installed
|
||||
if command -v act_runner >/dev/null 2>&1; then
|
||||
@@ -79,7 +90,7 @@ jobs:
|
||||
DEVX_VERSION_FILE: src/gitea_runner_manager/__init__.py
|
||||
DEVX_TASK_PREFIX: GRM
|
||||
run: |
|
||||
. .venv/bin/activate
|
||||
. .venv/bin/activate 2>/dev/null || true
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
python3 -m devx.ci.release --dry-run
|
||||
|
||||
@@ -105,12 +116,43 @@ jobs:
|
||||
PYTHONPATH: src
|
||||
DEVX_TASK_PREFIX: GRM
|
||||
run: |
|
||||
. .venv/bin/activate
|
||||
. .venv/bin/activate 2>/dev/null || true
|
||||
python3 -m devx.ci.classify_changes \
|
||||
--base "origin/master" \
|
||||
--head "${{ github.event.pull_request.head.sha || github.sha }}" \
|
||||
--github-output
|
||||
|
||||
pre-merge-check:
|
||||
needs: [quality, detect-changes]
|
||||
if: github.event_name == 'pull_request'
|
||||
runs-on: docker
|
||||
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-base:latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Set up environment
|
||||
run: make setup-image EXTRAS=ci
|
||||
- name: Validate auto-merge preconditions
|
||||
env:
|
||||
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
|
||||
VIKUNJA_TOKEN: ${{ secrets.VIKUNJA_TOKEN }}
|
||||
DEVX_TASK_PREFIX: GRM
|
||||
DEVX_VIKUNJA_PROJECT_ID: 6
|
||||
HEAD_REF: ${{ github.head_ref }}
|
||||
PR_TITLE: ${{ github.event.pull_request.title }}
|
||||
REPOSITORY: ${{ github.repository }}
|
||||
PR_NUMBER: ${{ github.event.number }}
|
||||
PYTHONPATH: ${{ env.PYTHONPATH }}
|
||||
run: |
|
||||
. .venv/bin/activate 2>/dev/null || true
|
||||
python3 -m devx.ci.check_auto_merge_ready \
|
||||
--branch "$HEAD_REF" \
|
||||
--pr-title "$PR_TITLE" \
|
||||
--repo "$REPOSITORY" \
|
||||
--pr-number "$PR_NUMBER"
|
||||
|
||||
discover-runners:
|
||||
needs: [detect-changes]
|
||||
if: needs.detect-changes.outputs.ansible-changed == 'true'
|
||||
@@ -134,7 +176,7 @@ jobs:
|
||||
MOLECULE_RUNNERS: ${{ vars.MOLECULE_RUNNERS }}
|
||||
PYTHONPATH: src
|
||||
run: |
|
||||
. .venv/bin/activate
|
||||
. .venv/bin/activate 2>/dev/null || true
|
||||
python3 -m devx.molecule.discover_runners \
|
||||
--owner "${{ github.repository_owner }}" \
|
||||
--repo "${{ github.event.repository.name }}" \
|
||||
@@ -147,8 +189,10 @@ jobs:
|
||||
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-full:latest
|
||||
timeout-minutes: 10
|
||||
strategy:
|
||||
fail-fast: true
|
||||
max-parallel: 3
|
||||
matrix:
|
||||
runner-index: [1, 2, 3]
|
||||
runner-index: [1, 2, 3, 4, 5, 6]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up environment
|
||||
@@ -158,7 +202,7 @@ jobs:
|
||||
run: make setup-image EXTRAS=ci,molecule
|
||||
- name: Install Ansible collections
|
||||
run: |
|
||||
. .venv/bin/activate
|
||||
. .venv/bin/activate 2>/dev/null || true
|
||||
python3 -m devx.tools.setup --skip-install --no-pre-commit --no-tea-login
|
||||
- name: Discover assigned test pairs
|
||||
env:
|
||||
@@ -166,7 +210,7 @@ jobs:
|
||||
MAX_RUNNERS: ${{ needs.discover-runners.outputs.runner-count }}
|
||||
PYTHONPATH: src
|
||||
run: |
|
||||
. .venv/bin/activate
|
||||
. .venv/bin/activate 2>/dev/null || true
|
||||
python3 -m devx.molecule.distribute_molecule \
|
||||
--runner-index "$RUNNER_INDEX" \
|
||||
--max-runners "$MAX_RUNNERS" \
|
||||
@@ -174,7 +218,7 @@ jobs:
|
||||
- name: Run molecule tests
|
||||
if: env.SKIP != 'true'
|
||||
run: |
|
||||
. .venv/bin/activate
|
||||
. .venv/bin/activate 2>/dev/null || true
|
||||
if [ -z "$TEST_PAIRS" ]; then exit 0; fi
|
||||
if ! python3 -c "import docker; docker.from_env().ping()" 2>/dev/null; then
|
||||
echo "Docker not available in CI container — skipping molecule tests"
|
||||
@@ -188,6 +232,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 +260,7 @@ jobs:
|
||||
PYTHONPATH: src
|
||||
run: |
|
||||
set -euo pipefail
|
||||
. .venv/bin/activate
|
||||
. .venv/bin/activate 2>/dev/null || true
|
||||
python3 -m devx.ci.pr_review \
|
||||
"${{ github.event.number }}" \
|
||||
"${{ github.repository }}"
|
||||
@@ -225,11 +270,12 @@ jobs:
|
||||
# from the branch name, validates the PR title, and squash-merges.
|
||||
# Uses always() so it evaluates even when molecule-tests is skipped
|
||||
# (Gitea Actions skips dependent jobs of skipped jobs by default).
|
||||
needs: [quality, detect-changes, pr-review, molecule-tests, release-dry-run]
|
||||
needs: [quality, detect-changes, pre-merge-check, pr-review, molecule-tests, release-dry-run]
|
||||
if: >-
|
||||
always() &&
|
||||
github.event_name == 'pull_request' &&
|
||||
needs.quality.result == 'success' &&
|
||||
needs.pre-merge-check.result == 'success' &&
|
||||
needs.pr-review.result == 'success' &&
|
||||
(needs.molecule-tests.result == 'success' || needs.molecule-tests.result == 'skipped') &&
|
||||
(needs.release-dry-run.result == 'success' || needs.release-dry-run.result == 'skipped')
|
||||
@@ -249,6 +295,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 +322,7 @@ jobs:
|
||||
REPOSITORY: ${{ github.repository }}
|
||||
PR_NUMBER: ${{ github.event.number }}
|
||||
run: |
|
||||
. .venv/bin/activate
|
||||
. .venv/bin/activate 2>/dev/null || true
|
||||
python3 -m devx.ci.auto_merge \
|
||||
"$HEAD_REF" \
|
||||
"$PR_TITLE" \
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -64,10 +64,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
|
||||
|
||||
@@ -98,8 +98,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 +119,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`).
|
||||
|
||||
@@ -132,8 +131,7 @@ Post review comments using `devx.ci.pr_review` (run as `python -m devx.ci.pr_rev
|
||||
```bash
|
||||
CI_GITEA_TOKEN=<token> python -m devx.ci.pr_review <pr_number> <owner/repo> \
|
||||
--event REQUEST_CHANGES \
|
||||
--body "Review summary" \
|
||||
--comments-json comments.json
|
||||
--body "Review summary"
|
||||
```
|
||||
|
||||
### 7. Address Review Comments
|
||||
@@ -146,7 +144,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 —
|
||||
@@ -165,6 +163,12 @@ Then add the `ready-to-merge` label. The auto-merge workflow will:
|
||||
5. The post-merge workflow marks the Vikunja task as done
|
||||
6. The release workflow automatically versions, tags, and publishes (see below)
|
||||
|
||||
**If the branch is behind master** (another PR merged first), auto-merge
|
||||
automatically rebases the PR's head branch via the Gitea API. This triggers
|
||||
a new CI run. The next auto-merge attempt will merge successfully.
|
||||
No manual rebase needed. To rebase manually: `make rebase` (local) or
|
||||
`make pr-rebase` (server-side via API).
|
||||
|
||||
> **IMPORTANT**: Never manually merge PRs via the API. Always use the auto-merge
|
||||
> workflow by adding the `ready-to-merge` label. Manual merges bypass the
|
||||
> `GRM-N: <conventional>` format enforcement, producing incorrectly named commits.
|
||||
@@ -173,6 +177,10 @@ Then add the `ready-to-merge` label. The auto-merge workflow will:
|
||||
|
||||
### CI Path Filtering
|
||||
|
||||
The CI workflow includes a `pre-merge-check` job (runs after quality +
|
||||
detect-changes) that validates branch format, PR title, and Vikunja task
|
||||
match. This fails fast before expensive molecule tests run.
|
||||
|
||||
The CI workflow includes a `detect-changes` job that checks whether any files
|
||||
under `ansible/` or `.ansible-lint` have changed. If no Ansible files are
|
||||
changed, molecule tests are skipped — this prevents non-Ansible changes
|
||||
@@ -244,32 +252,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
|
||||
@@ -285,7 +293,7 @@ via `[tool.devx.classify]` in `pyproject.toml`.
|
||||
|
||||
**AI agents must follow these rules:**
|
||||
- When working on workflow/CI/docs-only changes, use `ci:` or `docs:` commit prefixes
|
||||
- Do NOT bump the version or create tags for workflow-only changes
|
||||
- Do NOT bump the version or create tags for infrastructure-only changes
|
||||
- The `classify_changes` module enforces this automatically — no manual intervention needed
|
||||
|
||||
## Source Code Separation and devx Integration
|
||||
@@ -399,7 +407,7 @@ The devx package is configured via `DEVX_*` environment variables:
|
||||
- `DEVX_VIKUNJA_PROJECT_ID=6` — Vikunja project ID for task tracking
|
||||
- `DEVX_VERSION_FILE=src/gitea_runner_manager/__init__.py` — Path to the version source file
|
||||
|
||||
Change classification is config-driven via `[tool.devx.classify]` in `pyproject.toml`, which defines the workflow-only and user-facing path patterns.
|
||||
Change classification is config-driven via `[tool.devx.classify]` in `pyproject.toml`, which defines the infrastructure and user-facing path patterns.
|
||||
|
||||
## Key Conventions
|
||||
|
||||
@@ -424,10 +432,12 @@ main.yml → systemd_check → user_setup → rootless_docker → install_runner
|
||||
|
||||
## Molecule Scenarios
|
||||
|
||||
6 scenarios: `default`, `multi-instance`, `lifecycle`, `template-content`, `deregister`, `update`
|
||||
7 scenarios: `default`, `multi-instance`, `lifecycle`, `template-content`, `deregister`, `update`, `remove`
|
||||
4 platforms: `ubuntu-2204`, `ubuntu-2404`, `debian-12`, `archlinux`
|
||||
Platform list is defined in `devx.molecule.platforms` (single source of truth)
|
||||
|
||||
Note: `make molecule` and `make molecule-all` run 6 scenarios (excluding `remove`, which destroys the test container). CI discovers all 7 scenarios via `devx.molecule.distribute_molecule`.
|
||||
|
||||
## Known Issues
|
||||
|
||||
- `ansible-lint` may warn about `command-instead-of-module` for `systemctl --user` calls — this is expected (systemd module doesn't support user services) and skipped in `.ansible-lint`
|
||||
@@ -477,3 +487,86 @@ docs/
|
||||
2. If adding a new page, add it to `docs/mapping.json`
|
||||
3. Commit and create a PR (standard PR workflow)
|
||||
4. On merge, wiki is automatically synced
|
||||
|
||||
## Subagent Delegation Policy
|
||||
|
||||
Custom subagent profiles are defined in `.devin/agents/` (project-specific)
|
||||
and `~/.config/devin/agents/` (global, shared across repos). The agent MUST
|
||||
automatically delegate to the appropriate subagent based on the task —
|
||||
the user should not need to specify which profile to use.
|
||||
|
||||
### Available Profiles
|
||||
|
||||
**Global** (shared with infra and devx):
|
||||
|
||||
| Profile | Location | Purpose |
|
||||
|---------|----------|---------|
|
||||
| `pr-reviewer` | `~/.config/devin/agents/` | 13-category PR checklist + quality gates |
|
||||
| `release-check` | `~/.config/devin/agents/` | Pre-merge readiness validation |
|
||||
|
||||
**grm-specific** (in `.devin/agents/`):
|
||||
|
||||
| Profile | Purpose |
|
||||
|---------|---------|
|
||||
| `ci-investigator` | Investigate CI failures (quality, molecule, release, publish, wiki sync) |
|
||||
| `molecule-runner` | Run 7 molecule scenarios across 4 platforms, report pass/fail |
|
||||
| `dep-upgrader` | Python + Ansible dependency upgrades with molecule verification |
|
||||
| `doc-syncer` | Doc coverage, doc linting, wiki sync for grm docs |
|
||||
| `workflow-validator` | actionlint + act_runner dry-run for grm workflows |
|
||||
|
||||
### When to Delegate Automatically
|
||||
|
||||
| Trigger | Profile | Mode |
|
||||
|---------|---------|------|
|
||||
| CI run failure (quality, molecule-tests, release, publish, sync-wiki) | `ci-investigator` | Background |
|
||||
| PR ready for review | `pr-reviewer` | Foreground |
|
||||
| Molecule tests need to run | `molecule-runner` | Background |
|
||||
| Dependency upgrade requested | `dep-upgrader` | Background |
|
||||
| Doc coverage failure or wiki sync issue | `doc-syncer` | Background |
|
||||
| Workflow YAML modified or validation needed | `workflow-validator` | Background |
|
||||
| Branch ready for merge | `release-check` | Foreground |
|
||||
|
||||
### Delegation Rules
|
||||
|
||||
1. **Auto-select the profile.** Do not ask the user which profile to use.
|
||||
2. **Background by default, foreground when blocking.**
|
||||
3. **Provide full context in the prompt** — subagents don't inherit conversation history.
|
||||
4. **One subagent per concern.** Chain: investigate → fix in main session → review.
|
||||
5. **Don't delegate trivial work** (<30s, <50 lines of context).
|
||||
6. **Compact after subagent returns.**
|
||||
7. **Never skip delegation to save time** — it keeps main context small.
|
||||
|
||||
|
||||
## Feedback Issue Handling
|
||||
|
||||
Subagents create Gitea issues in the current repo when they encounter
|
||||
tool, workflow, or process issues that warrant follow-up. These issues
|
||||
use the `feedback` label plus a category label (`tooling`,
|
||||
`ci-improvement`, `doc-improvement`, `workflow-improvement`).
|
||||
|
||||
Standard labels are created automatically by `configure_repo` (runs in
|
||||
post-merge on every master push). If a label does not exist yet, the
|
||||
subagent's issue creation will still succeed — labels can be added
|
||||
afterwards.
|
||||
|
||||
### When a Subagent Reports a Feedback Issue URL
|
||||
|
||||
1. **Acknowledge it** in your response to the user — mention the issue URL
|
||||
2. **Do NOT close or modify** the issue — it is for follow-up work
|
||||
3. **Do NOT create a PR** to address it unless the user explicitly asks
|
||||
4. If the user asks to address feedback, spawn a subagent to investigate
|
||||
the issue and implement a fix
|
||||
|
||||
### Creating Feedback Issues Manually
|
||||
|
||||
As the parent agent, you can also create feedback issues directly using
|
||||
the Gitea MCP (`issue_write` with `create_issue` method). Follow the
|
||||
same format as subagents:
|
||||
|
||||
- Title: `[feedback] <category>: <short description>`
|
||||
- Labels: `feedback` + category label
|
||||
- Body: include context, tool/workflow, issue, reproduction, affected
|
||||
files, suggested investigation, and "Reported by: parent agent"
|
||||
|
||||
Always deduplicate first via `list_issues` with `labels: "feedback"`.
|
||||
|
||||
|
||||
@@ -2,6 +2,68 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [0.14.0] - 2026-07-01
|
||||
|
||||
### Features
|
||||
|
||||
- Bump devx to v0.30.0
|
||||
|
||||
## [0.13.0] - 2026-07-01
|
||||
|
||||
### Features
|
||||
|
||||
- Bump devx to v0.29.1, upgrade molecule, ubuntu 26.04
|
||||
|
||||
## [0.12.5] - 2026-06-30
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Right-size molecule-tests matrix to [1-6]
|
||||
- Cast disk threshold to string in template-content verify assertion
|
||||
|
||||
## [0.12.4] - 2026-06-29
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Use hardcoded matrix array for Gitea 1.26 compatibility
|
||||
|
||||
## [0.12.3] - 2026-06-29
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- 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
|
||||
|
||||
- Unified --become-password-file, --verbose, --no-status, labels fix
|
||||
|
||||
## [0.10.3] - 2026-06-27
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -109,28 +109,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
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -143,14 +143,20 @@ GRM provides a single `grm` command with subcommands for the full runner lifecyc
|
||||
| `grm update <host>` | Update the Gitea Runner binary on a remote host |
|
||||
| `grm start <name>` | Start a registered runner |
|
||||
| `grm stop <name>` | Stop a registered runner |
|
||||
| `grm restart <name>` | Restart a runner (stop, prune Docker images, start) |
|
||||
| `grm enable <name>` | Enable a runner to start on boot |
|
||||
| `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`, `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`.
|
||||
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`.
|
||||
|
||||
See the [CLI Commands](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/wiki/CLI-Commands.-) wiki page for full argument and option reference.
|
||||
|
||||
@@ -174,8 +180,73 @@ GRM reads configuration from a `.env` file in the current directory (loaded auto
|
||||
| `GITEA_RUNNER_USER` | current login | Default SSH user (overrides `--user`) |
|
||||
| `GITEA_RUNNER_KEY` | — | Default SSH key path (overrides `--key`) |
|
||||
| `GITEA_RUNNER_LABELS` | — | Default runner labels (overrides `--labels`) |
|
||||
| `GRM_LANG` | `en` | UI language: `en`, `bg`, `de`, `ru`, `zh` |
|
||||
| `GRM_LANG` | `en` | UI language: `en`, `bg`, `de`, `ru`, `zh`, `pl` |
|
||||
| `GRM_LOG_LEVEL` | `INFO` | Console log level: `DEBUG`, `INFO`, `WARNING`, `ERROR`, `CRITICAL` |
|
||||
| `GRM_BECOME_PASSWORD_FILE` | — | Path to file containing sudo password (see [Sudo Password Handling](#sudo-password-handling)) |
|
||||
| `ANSIBLE_BECOME_PASSWORD_FILE` | — | Fallback sudo password file path (Ansible-native env var) |
|
||||
|
||||
### Sudo Password Handling
|
||||
|
||||
GRM delegates remote operations to Ansible, which uses `sudo` (become) on the target host. There are several ways to provide the sudo password, in priority order:
|
||||
|
||||
1. **`--become-password-file <path>`** (CLI flag, global) — Read sudo password from a file. Works for all commands including `grm list`.
|
||||
2. **`GRM_BECOME_PASSWORD_FILE`** (env var) — Same as above, set in `.env` or environment.
|
||||
3. **`ANSIBLE_BECOME_PASSWORD_FILE`** (env var) — Fallback, Ansible-native env var.
|
||||
4. **Interactive prompt** — If none of the above are set, GRM prompts for the sudo password (hidden input).
|
||||
5. **Piped stdin** — When stdin is not a TTY, reads the first line: `echo 'password' | grm list`.
|
||||
6. **`--no-ask-become-pass`** — Skip sudo password entirely (use when the target user has passwordless sudo).
|
||||
|
||||
For `grm list` specifically, the password is collected once and reused for all runner status checks via `--become-password-file`, avoiding stdin consumption issues when checking multiple runners.
|
||||
|
||||
**Examples:**
|
||||
|
||||
```bash
|
||||
# Interactive prompt (default)
|
||||
grm install 192.168.1.10 --user ubuntu
|
||||
|
||||
# Password file (recommended for automation)
|
||||
echo 'my-sudo-pass' > ~/.grm-sudo-pass
|
||||
chmod 600 ~/.grm-sudo-pass
|
||||
grm --become-password-file ~/.grm-sudo-pass install 192.168.1.10 --user ubuntu
|
||||
|
||||
# Env var (set in .env)
|
||||
GRM_BECOME_PASSWORD_FILE=~/.grm-sudo-pass
|
||||
grm list # uses the file automatically
|
||||
|
||||
# Piped stdin (for scripts)
|
||||
echo 'my-sudo-pass' | grm list
|
||||
|
||||
# Passwordless sudo on target
|
||||
grm install 192.168.1.10 --user ubuntu --no-ask-become-pass
|
||||
```
|
||||
|
||||
### Verbose Output
|
||||
|
||||
Pass `-v` / `--verbose` (global flag, before the subcommand) to enable Ansible verbose mode (`-v`):
|
||||
|
||||
```bash
|
||||
grm --verbose install 192.168.1.10 --user ubuntu
|
||||
grm -v status prod-runner
|
||||
```
|
||||
|
||||
### Runner Labels
|
||||
|
||||
Runner labels control which jobs a runner accepts. They are set at installation time:
|
||||
|
||||
- **`--labels "docker:docker://alpine:latest"`** — Set specific labels.
|
||||
- **`--labels ""`** — Explicitly set **no labels** (overrides `GITEA_RUNNER_LABELS` env var).
|
||||
- **No `--labels` flag** — Uses `GITEA_RUNNER_LABELS` env var if set, otherwise the Ansible role default.
|
||||
|
||||
```bash
|
||||
# Custom labels
|
||||
grm install 192.168.1.10 --user ubuntu --labels "docker:docker://alpine:latest,ubuntu-22.04:docker://ubuntu:22.04"
|
||||
|
||||
# Explicitly no labels (overrides GITEA_RUNNER_LABELS env var)
|
||||
grm install 192.168.1.10 --user ubuntu --labels ""
|
||||
|
||||
# Use GITEA_RUNNER_LABELS from .env (or role default if unset)
|
||||
grm install 192.168.1.10 --user ubuntu
|
||||
```
|
||||
|
||||
### Getting tokens
|
||||
|
||||
@@ -293,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
|
||||
|
||||
|
||||
+19
-23
@@ -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.23.0",
|
||||
"devx==0.30.0",
|
||||
]
|
||||
# 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.23.0",
|
||||
"devx==0.30.0",
|
||||
# Non-Python dev dependency: checkmake (Makefile linter)
|
||||
# Install via: go install github.com/checkmake/checkmake/cmd/checkmake@latest
|
||||
]
|
||||
@@ -100,7 +99,7 @@ strict = ["src/gitea_runner_manager"]
|
||||
task_prefix = "GRM"
|
||||
vikunja_project_id = 6
|
||||
repo_owner = "oblachno-oss"
|
||||
repo_name = "gitea-runner-manager"
|
||||
repo_name = "grm"
|
||||
|
||||
# Molecule test weights for LPT scheduling.
|
||||
# GRM has a single role (gitea-runner) with 7 scenarios.
|
||||
@@ -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.10.3"
|
||||
__version__ = "0.14.0"
|
||||
|
||||
@@ -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)
|
||||
+226
-29
@@ -6,6 +6,7 @@ import functools
|
||||
import os
|
||||
import sys
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import click
|
||||
@@ -13,12 +14,41 @@ 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
|
||||
|
||||
load_dotenv(override=True)
|
||||
|
||||
|
||||
def _default_user() -> str:
|
||||
"""Default SSH user from env or current OS user."""
|
||||
if user := os.getenv("GITEA_RUNNER_USER"):
|
||||
return user
|
||||
try:
|
||||
return os.getlogin()
|
||||
except OSError:
|
||||
return os.getenv("USER", "root")
|
||||
|
||||
|
||||
def _get_become_password_file() -> str | None:
|
||||
"""Read become-password-file from Click context or env vars."""
|
||||
ctx = click.get_current_context(silent=True)
|
||||
if ctx and ctx.obj:
|
||||
path = ctx.obj.get("become_password_file")
|
||||
if path:
|
||||
return path
|
||||
return os.getenv("GRM_BECOME_PASSWORD_FILE") or os.getenv("ANSIBLE_BECOME_PASSWORD_FILE")
|
||||
|
||||
|
||||
def _get_verbose() -> bool:
|
||||
"""Read verbose flag from Click context."""
|
||||
ctx = click.get_current_context(silent=True)
|
||||
if ctx and ctx.obj:
|
||||
return ctx.obj.get("verbose", False)
|
||||
return False
|
||||
|
||||
|
||||
def _runner_options(func: Callable[..., Any]) -> Callable[..., Any]:
|
||||
"""Apply common override options for registry-based lifecycle commands."""
|
||||
func = click.option(
|
||||
@@ -50,8 +80,25 @@ def _handle_errors(msg_key: str) -> Callable[[Callable[..., Any]], Callable[...,
|
||||
|
||||
@click.group(help=_("Gitea Runner Manager — manage Gitea Actions runners."))
|
||||
@click.version_option(version=__version__)
|
||||
def cli() -> None:
|
||||
pass
|
||||
@click.option(
|
||||
"--become-password-file",
|
||||
envvar="GRM_BECOME_PASSWORD_FILE",
|
||||
type=click.Path(exists=True, dir_okay=False, readable=True),
|
||||
default=None,
|
||||
help=_("Read sudo password from a file instead of prompting (env: GRM_BECOME_PASSWORD_FILE)"),
|
||||
)
|
||||
@click.option(
|
||||
"--verbose",
|
||||
"-v",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help=_("Enable verbose Ansible output (-v flag passed to ansible)"),
|
||||
)
|
||||
@click.pass_context
|
||||
def cli(ctx: click.Context, become_password_file: str | None, verbose: bool) -> None:
|
||||
ctx.ensure_object(dict)
|
||||
ctx.obj["become_password_file"] = become_password_file
|
||||
ctx.obj["verbose"] = verbose
|
||||
|
||||
|
||||
@cli.command(help=_("Install and configure a Gitea Runner on a remote host."))
|
||||
@@ -59,8 +106,8 @@ def cli() -> None:
|
||||
@click.option(
|
||||
"--user",
|
||||
"-u",
|
||||
default=lambda: os.getenv("GITEA_RUNNER_USER", os.getlogin()),
|
||||
help=_("SSH user"),
|
||||
default=_default_user,
|
||||
help=_("SSH user (env: GITEA_RUNNER_USER)"),
|
||||
)
|
||||
@click.option("--key", "-k", default=lambda: os.getenv("GITEA_RUNNER_KEY"), help=_("Path to SSH private key"))
|
||||
@click.option("--name", "-n", help=_("Gitea Runner name (default: host)"))
|
||||
@@ -91,14 +138,19 @@ def cli() -> None:
|
||||
@click.option(
|
||||
"--labels",
|
||||
"-l",
|
||||
default=lambda: os.getenv("GITEA_RUNNER_LABELS", ""),
|
||||
help=_("Runner labels for Gitea Actions (env: GITEA_RUNNER_LABELS). Example: docker:docker://alpine:latest"),
|
||||
default=None,
|
||||
help=_(
|
||||
"Runner labels (env: GITEA_RUNNER_LABELS). "
|
||||
"Pass an empty string for no labels. "
|
||||
"Example: docker:docker://alpine:latest"
|
||||
),
|
||||
)
|
||||
@click.option(
|
||||
"--ask-become-pass/--no-ask-become-pass",
|
||||
default=True,
|
||||
help=_("Prompt for sudo password (default)"),
|
||||
)
|
||||
@_handle_errors("Installation failed: {error}")
|
||||
def install(
|
||||
host: str,
|
||||
user: str,
|
||||
@@ -108,25 +160,26 @@ def install(
|
||||
url: str,
|
||||
admin_token: str | None,
|
||||
integration_retries: int,
|
||||
labels: str,
|
||||
labels: str | None,
|
||||
ask_become_pass: bool,
|
||||
) -> None:
|
||||
if labels is None:
|
||||
labels = os.getenv("GITEA_RUNNER_LABELS")
|
||||
manager = RunnerManager()
|
||||
try:
|
||||
manager.install(
|
||||
host=host,
|
||||
user=user,
|
||||
key=key,
|
||||
name=name,
|
||||
token=token,
|
||||
gitea_url=url,
|
||||
admin_token=admin_token,
|
||||
integration_retries=integration_retries,
|
||||
labels=labels or None,
|
||||
ask_become_pass=ask_become_pass,
|
||||
)
|
||||
except GRMError as e:
|
||||
raise click.ClickException(_("Installation failed: {error}", error=e)) from e
|
||||
manager.install(
|
||||
host=host,
|
||||
user=user,
|
||||
key=key,
|
||||
name=name,
|
||||
token=token,
|
||||
gitea_url=url,
|
||||
admin_token=admin_token,
|
||||
integration_retries=integration_retries,
|
||||
labels=labels,
|
||||
ask_become_pass=ask_become_pass,
|
||||
become_password_file=_get_become_password_file(),
|
||||
verbose=_get_verbose(),
|
||||
)
|
||||
|
||||
|
||||
@cli.command(help=_("Update the Gitea Runner binary on a remote host."))
|
||||
@@ -134,8 +187,8 @@ def install(
|
||||
@click.option(
|
||||
"--user",
|
||||
"-u",
|
||||
default=lambda: os.getenv("GITEA_RUNNER_USER", os.getlogin()),
|
||||
help=_("SSH user"),
|
||||
default=_default_user,
|
||||
help=_("SSH user (env: GITEA_RUNNER_USER)"),
|
||||
)
|
||||
@click.option("--key", "-k", default=lambda: os.getenv("GITEA_RUNNER_KEY"), help=_("Path to SSH private key"))
|
||||
@click.option("--version", "-v", help=_("Specific Gitea Runner version"))
|
||||
@@ -159,6 +212,8 @@ def update(
|
||||
key=key,
|
||||
version=version,
|
||||
ask_become_pass=ask_become_pass,
|
||||
become_password_file=_get_become_password_file(),
|
||||
verbose=_get_verbose(),
|
||||
)
|
||||
|
||||
|
||||
@@ -180,6 +235,8 @@ def start(
|
||||
user=user,
|
||||
key=key,
|
||||
ask_become_pass=ask_become_pass,
|
||||
become_password_file=_get_become_password_file(),
|
||||
verbose=_get_verbose(),
|
||||
)
|
||||
|
||||
|
||||
@@ -201,6 +258,8 @@ def stop(
|
||||
user=user,
|
||||
key=key,
|
||||
ask_become_pass=ask_become_pass,
|
||||
become_password_file=_get_become_password_file(),
|
||||
verbose=_get_verbose(),
|
||||
)
|
||||
|
||||
|
||||
@@ -222,6 +281,8 @@ def restart(
|
||||
user=user,
|
||||
key=key,
|
||||
ask_become_pass=ask_become_pass,
|
||||
become_password_file=_get_become_password_file(),
|
||||
verbose=_get_verbose(),
|
||||
)
|
||||
|
||||
|
||||
@@ -243,6 +304,8 @@ def enable(
|
||||
user=user,
|
||||
key=key,
|
||||
ask_become_pass=ask_become_pass,
|
||||
become_password_file=_get_become_password_file(),
|
||||
verbose=_get_verbose(),
|
||||
)
|
||||
|
||||
|
||||
@@ -279,6 +342,8 @@ def disable(
|
||||
token=token,
|
||||
gitea_url=url,
|
||||
ask_become_pass=ask_become_pass,
|
||||
become_password_file=_get_become_password_file(),
|
||||
verbose=_get_verbose(),
|
||||
)
|
||||
|
||||
|
||||
@@ -300,6 +365,8 @@ def status(
|
||||
user=user,
|
||||
key=key,
|
||||
ask_become_pass=ask_become_pass,
|
||||
become_password_file=_get_become_password_file(),
|
||||
verbose=_get_verbose(),
|
||||
)
|
||||
|
||||
|
||||
@@ -344,15 +411,22 @@ def remove(
|
||||
gitea_url=url,
|
||||
ask_become_pass=ask_become_pass,
|
||||
force=force,
|
||||
become_password_file=_get_become_password_file(),
|
||||
verbose=_get_verbose(),
|
||||
)
|
||||
|
||||
|
||||
def _collect_become_pass(ask_become_pass: bool) -> str | None:
|
||||
"""Collect sudo password for ad-hoc status checks.
|
||||
|
||||
When stdin is a TTY, prompts interactively with hidden input.
|
||||
When stdin is piped, reads the first line (e.g. ``echo 'pass' | grm list``).
|
||||
Priority:
|
||||
1. ``--become-password-file`` / ``GRM_BECOME_PASSWORD_FILE`` env var
|
||||
2. ``ANSIBLE_BECOME_PASSWORD_FILE`` env var
|
||||
3. Interactive prompt (TTY) or piped stdin (first line)
|
||||
"""
|
||||
password_file = _get_become_password_file()
|
||||
if password_file:
|
||||
return Path(password_file).read_text(encoding="utf-8").strip() or None
|
||||
if not ask_become_pass:
|
||||
return None
|
||||
if sys.stdin.isatty():
|
||||
@@ -368,18 +442,62 @@ 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",
|
||||
default=True,
|
||||
help=_("Prompt for sudo password once for all status checks (default)."),
|
||||
)
|
||||
@click.option(
|
||||
"--no-status",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help=_("Skip live SSH status checks and show registry entries only"),
|
||||
)
|
||||
@_handle_errors("List failed: {error}")
|
||||
def list_runners(ask_become_pass: bool) -> None:
|
||||
become_pass = _collect_become_pass(ask_become_pass)
|
||||
def list_runners(ask_become_pass: bool, no_status: bool) -> None:
|
||||
become_pass = None if no_status else _collect_become_pass(ask_become_pass)
|
||||
|
||||
manager = RunnerManager()
|
||||
runners = manager.list_runners(become_pass=become_pass)
|
||||
runners = manager.list_runners(become_pass=become_pass, no_status=no_status)
|
||||
|
||||
if not runners:
|
||||
click.echo(_("No runners registered. Use 'grm install' to add one."))
|
||||
@@ -389,3 +507,82 @@ def list_runners(ask_become_pass: 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}")
|
||||
|
||||
@@ -116,12 +116,16 @@ class AnsibleExecutor:
|
||||
if become and ask_become_pass:
|
||||
if become_pass:
|
||||
fd, password_file = tempfile.mkstemp(suffix=".txt", prefix="grm-become-")
|
||||
os.fchmod(fd, 0o600)
|
||||
with os.fdopen(fd, "w") as f:
|
||||
f.write(become_pass)
|
||||
os.chmod(password_file, 0o600)
|
||||
cmd.extend(["--become-password-file", password_file])
|
||||
else:
|
||||
cmd.append("--ask-become-pass")
|
||||
env_password_file = os.getenv("ANSIBLE_BECOME_PASSWORD_FILE")
|
||||
if env_password_file:
|
||||
cmd.extend(["--become-password-file", env_password_file])
|
||||
else:
|
||||
cmd.append("--ask-become-pass")
|
||||
|
||||
env = os.environ.copy()
|
||||
try:
|
||||
|
||||
@@ -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)
|
||||
@@ -7,12 +7,22 @@ Supported: en, bg, de, ru, zh, pl.
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
TRANSLATIONS: dict[str, dict[str, str]] = json.loads(
|
||||
(Path(__file__).parent / "translations.json").read_text(encoding="utf-8")
|
||||
)
|
||||
logger = logging.getLogger("grm")
|
||||
|
||||
|
||||
def _load_translations() -> dict[str, dict[str, str]]:
|
||||
try:
|
||||
return json.loads((Path(__file__).parent / "translations.json").read_text(encoding="utf-8"))
|
||||
except (json.JSONDecodeError, OSError) as e:
|
||||
logger.warning("Failed to load translations.json: %s — falling back to English", e)
|
||||
return {}
|
||||
|
||||
|
||||
TRANSLATIONS: dict[str, dict[str, str]] = _load_translations()
|
||||
|
||||
|
||||
def _(key: str, **kwargs: object) -> str:
|
||||
|
||||
@@ -6,6 +6,7 @@ so that subsequent lifecycle commands only need the runner name.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import fcntl
|
||||
import json
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
@@ -23,20 +24,29 @@ class RunnerRegistry:
|
||||
self._data: dict[str, dict[str, Any]] = self._load()
|
||||
|
||||
def _load(self) -> dict[str, dict[str, Any]]:
|
||||
if self._path.exists():
|
||||
try:
|
||||
with open(self._path) as f:
|
||||
if not self._path.exists():
|
||||
return {}
|
||||
try:
|
||||
with open(self._path) as f:
|
||||
fcntl.flock(f.fileno(), fcntl.LOCK_SH)
|
||||
try:
|
||||
data: Any = json.load(f)
|
||||
if isinstance(data, dict):
|
||||
return cast(dict[str, dict[str, Any]], data)
|
||||
except (json.JSONDecodeError, OSError):
|
||||
pass
|
||||
finally:
|
||||
fcntl.flock(f.fileno(), fcntl.LOCK_UN)
|
||||
except (json.JSONDecodeError, OSError):
|
||||
pass
|
||||
return {}
|
||||
|
||||
def _save(self) -> None:
|
||||
self._path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(self._path, "w") as f:
|
||||
json.dump(self._data, f, indent=2)
|
||||
fcntl.flock(f.fileno(), fcntl.LOCK_EX)
|
||||
try:
|
||||
json.dump(self._data, f, indent=2)
|
||||
finally:
|
||||
fcntl.flock(f.fileno(), fcntl.LOCK_UN)
|
||||
|
||||
def add(
|
||||
self,
|
||||
|
||||
@@ -49,7 +49,6 @@ def track_steps() -> Generator[StepTracker, None, None]:
|
||||
for step in reversed(tracker.steps):
|
||||
if step.status == "in_progress":
|
||||
step.status = "failed"
|
||||
break
|
||||
raise
|
||||
finally:
|
||||
_print_report(tracker.steps)
|
||||
|
||||
@@ -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
|
||||
@@ -42,12 +41,12 @@ class RunnerManager:
|
||||
return
|
||||
fd, path = tempfile.mkstemp(suffix=".json", prefix="grm-vars-")
|
||||
try:
|
||||
os.fchmod(fd, 0o600)
|
||||
with os.fdopen(fd, "w") as f:
|
||||
json.dump(extra_vars, f)
|
||||
os.chmod(path, 0o600)
|
||||
yield path
|
||||
finally:
|
||||
with contextlib.suppress(FileNotFoundError):
|
||||
with suppress(FileNotFoundError):
|
||||
os.unlink(path)
|
||||
|
||||
def _run_playbook(
|
||||
@@ -59,10 +58,21 @@ class RunnerManager:
|
||||
key: str | None = None,
|
||||
ask_become_pass: bool = False,
|
||||
description: str = "",
|
||||
become_password_file: str | None = None,
|
||||
verbose: bool = False,
|
||||
) -> None:
|
||||
"""Build command with temp-file extra-vars and execute via executor."""
|
||||
with self._extra_vars_file(extra_vars) as vars_file:
|
||||
cmd = self._build_cmd(playbook_name, host, user, vars_file, key, ask_become_pass)
|
||||
cmd = self._build_cmd(
|
||||
playbook_name,
|
||||
host,
|
||||
user,
|
||||
vars_file,
|
||||
key,
|
||||
ask_become_pass,
|
||||
become_password_file,
|
||||
verbose,
|
||||
)
|
||||
self._executor.run(cmd, description=description)
|
||||
|
||||
def install(
|
||||
@@ -77,6 +87,8 @@ class RunnerManager:
|
||||
integration_retries: int = 3,
|
||||
ask_become_pass: bool = False,
|
||||
labels: str | None = None,
|
||||
become_password_file: str | None = None,
|
||||
verbose: bool = False,
|
||||
) -> None:
|
||||
"""Install a runner on a remote host using Ansible."""
|
||||
if not name:
|
||||
@@ -94,7 +106,7 @@ class RunnerManager:
|
||||
}
|
||||
if admin_token:
|
||||
extra_vars["gitea_admin_token"] = admin_token
|
||||
if labels:
|
||||
if labels is not None:
|
||||
extra_vars["runner_labels"] = labels
|
||||
|
||||
with track_steps() as tracker:
|
||||
@@ -107,6 +119,8 @@ class RunnerManager:
|
||||
key,
|
||||
ask_become_pass,
|
||||
description=_("Installing Gitea Runner on {host}", host=host),
|
||||
become_password_file=become_password_file,
|
||||
verbose=verbose,
|
||||
)
|
||||
tracker.done()
|
||||
|
||||
@@ -128,6 +142,8 @@ class RunnerManager:
|
||||
key: str | None = None,
|
||||
version: str | None = None,
|
||||
ask_become_pass: bool = False,
|
||||
become_password_file: str | None = None,
|
||||
verbose: bool = False,
|
||||
) -> None:
|
||||
"""Update the gitea_runner binary on a remote host."""
|
||||
extra_vars: dict[str, str | int] | None = None
|
||||
@@ -144,6 +160,8 @@ class RunnerManager:
|
||||
key,
|
||||
ask_become_pass,
|
||||
description=_("Updating Gitea Runner on {host}", host=host),
|
||||
become_password_file=become_password_file,
|
||||
verbose=verbose,
|
||||
)
|
||||
tracker.done()
|
||||
|
||||
@@ -187,6 +205,8 @@ class RunnerManager:
|
||||
user: str | None = None,
|
||||
key: str | None = None,
|
||||
ask_become_pass: bool = False,
|
||||
become_password_file: str | None = None,
|
||||
verbose: bool = False,
|
||||
) -> None:
|
||||
"""Start a runner instance on a remote host."""
|
||||
actual_host, actual_user, actual_key, _gitea_url = self._resolve_runner(name, host, user, key)
|
||||
@@ -200,6 +220,8 @@ class RunnerManager:
|
||||
actual_key,
|
||||
ask_become_pass,
|
||||
description=_("Starting Gitea Runner {name} on {host}", name=name, host=actual_host),
|
||||
become_password_file=become_password_file,
|
||||
verbose=verbose,
|
||||
)
|
||||
tracker.done()
|
||||
|
||||
@@ -210,6 +232,8 @@ class RunnerManager:
|
||||
user: str | None = None,
|
||||
key: str | None = None,
|
||||
ask_become_pass: bool = False,
|
||||
become_password_file: str | None = None,
|
||||
verbose: bool = False,
|
||||
) -> None:
|
||||
"""Stop a runner instance on a remote host."""
|
||||
actual_host, actual_user, actual_key, _gitea_url = self._resolve_runner(name, host, user, key)
|
||||
@@ -223,6 +247,8 @@ class RunnerManager:
|
||||
actual_key,
|
||||
ask_become_pass,
|
||||
description=_("Stopping Gitea Runner {name} on {host}", name=name, host=actual_host),
|
||||
become_password_file=become_password_file,
|
||||
verbose=verbose,
|
||||
)
|
||||
tracker.done()
|
||||
|
||||
@@ -233,6 +259,8 @@ class RunnerManager:
|
||||
user: str | None = None,
|
||||
key: str | None = None,
|
||||
ask_become_pass: bool = False,
|
||||
become_password_file: str | None = None,
|
||||
verbose: bool = False,
|
||||
) -> None:
|
||||
"""Restart a runner instance on a remote host (stop, prune images, start)."""
|
||||
actual_host, actual_user, actual_key, _gitea_url = self._resolve_runner(name, host, user, key)
|
||||
@@ -246,6 +274,8 @@ class RunnerManager:
|
||||
actual_key,
|
||||
ask_become_pass,
|
||||
description=_("Restarting Gitea Runner {name} on {host}", name=name, host=actual_host),
|
||||
become_password_file=become_password_file,
|
||||
verbose=verbose,
|
||||
)
|
||||
tracker.done()
|
||||
|
||||
@@ -256,6 +286,8 @@ class RunnerManager:
|
||||
user: str | None = None,
|
||||
key: str | None = None,
|
||||
ask_become_pass: bool = False,
|
||||
become_password_file: str | None = None,
|
||||
verbose: bool = False,
|
||||
) -> None:
|
||||
"""Enable a runner instance to start on boot."""
|
||||
actual_host, actual_user, actual_key, _gitea_url = self._resolve_runner(name, host, user, key)
|
||||
@@ -269,6 +301,8 @@ class RunnerManager:
|
||||
actual_key,
|
||||
ask_become_pass,
|
||||
description=_("Enabling Gitea Runner {name} on {host}", name=name, host=actual_host),
|
||||
become_password_file=become_password_file,
|
||||
verbose=verbose,
|
||||
)
|
||||
tracker.done()
|
||||
|
||||
@@ -281,6 +315,8 @@ class RunnerManager:
|
||||
token: str | None = None,
|
||||
gitea_url: str = "",
|
||||
ask_become_pass: bool = False,
|
||||
become_password_file: str | None = None,
|
||||
verbose: bool = False,
|
||||
) -> None:
|
||||
"""Disable and deregister a runner instance."""
|
||||
actual_host, actual_user, actual_key, registry_gitea_url = self._resolve_runner(name, host, user, key)
|
||||
@@ -299,6 +335,8 @@ class RunnerManager:
|
||||
actual_key,
|
||||
ask_become_pass,
|
||||
description=_("Disabling Gitea Runner {name} on {host}", name=name, host=actual_host),
|
||||
become_password_file=become_password_file,
|
||||
verbose=verbose,
|
||||
)
|
||||
tracker.done()
|
||||
|
||||
@@ -309,6 +347,8 @@ class RunnerManager:
|
||||
user: str | None = None,
|
||||
key: str | None = None,
|
||||
ask_become_pass: bool = False,
|
||||
become_password_file: str | None = None,
|
||||
verbose: bool = False,
|
||||
) -> None:
|
||||
"""Check the status of a runner instance."""
|
||||
actual_host, actual_user, actual_key, _gitea_url = self._resolve_runner(name, host, user, key)
|
||||
@@ -322,6 +362,8 @@ class RunnerManager:
|
||||
actual_key,
|
||||
ask_become_pass,
|
||||
description=_("Checking status of Gitea Runner {name} on {host}", name=name, host=actual_host),
|
||||
become_password_file=become_password_file,
|
||||
verbose=verbose,
|
||||
)
|
||||
tracker.done()
|
||||
|
||||
@@ -335,6 +377,8 @@ class RunnerManager:
|
||||
gitea_url: str = "",
|
||||
ask_become_pass: bool = False,
|
||||
force: bool = False,
|
||||
become_password_file: str | None = None,
|
||||
verbose: bool = False,
|
||||
) -> None:
|
||||
"""Remove a runner instance completely.
|
||||
|
||||
@@ -361,6 +405,8 @@ class RunnerManager:
|
||||
actual_key,
|
||||
ask_become_pass,
|
||||
description=_("Removing Gitea Runner {name} from {host}", name=name, host=actual_host),
|
||||
become_password_file=become_password_file,
|
||||
verbose=verbose,
|
||||
)
|
||||
tracker.done()
|
||||
|
||||
@@ -368,13 +414,18 @@ class RunnerManager:
|
||||
self._registry.remove(name)
|
||||
tracker.done()
|
||||
|
||||
def list_runners(self, become_pass: str | None = None) -> list[dict[str, str]]:
|
||||
def list_runners(
|
||||
self,
|
||||
become_pass: str | None = None,
|
||||
no_status: bool = False,
|
||||
) -> list[dict[str, str]]:
|
||||
"""Return a list of registered runners with live service status.
|
||||
|
||||
Args:
|
||||
become_pass: Sudo password for ad-hoc status checks. When provided,
|
||||
it is passed via ``--become-password-file`` to avoid stdin
|
||||
consumption issues when checking multiple runners in sequence.
|
||||
no_status: Skip live SSH status checks and return ``"n/a"`` for status.
|
||||
"""
|
||||
runners = self._registry.list()
|
||||
result: list[dict[str, str]] = []
|
||||
@@ -382,6 +433,19 @@ class RunnerManager:
|
||||
host = info["host"]
|
||||
user = info["user"]
|
||||
key = info.get("key")
|
||||
|
||||
if no_status:
|
||||
result.append(
|
||||
{
|
||||
"name": name,
|
||||
"host": host,
|
||||
"user": user,
|
||||
"labels": info.get("labels", ""),
|
||||
"status": _("n/a"),
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
say(
|
||||
_(
|
||||
"Checking status of Gitea Runner {name} on {host} as {user} (sudo required)",
|
||||
@@ -402,7 +466,7 @@ class RunnerManager:
|
||||
f"XDG_RUNTIME_DIR=/run/user/$(id -u grm-{name}) "
|
||||
f"systemctl --user is-active gitea-runner 2>/dev/null",
|
||||
become=True,
|
||||
ask_become_pass=True,
|
||||
ask_become_pass=become_pass is not None,
|
||||
check=False,
|
||||
become_pass=become_pass,
|
||||
)
|
||||
@@ -423,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:", ">>")
|
||||
@@ -437,6 +563,8 @@ class RunnerManager:
|
||||
extra_vars_file: str | None = None,
|
||||
key: str | None = None,
|
||||
ask_become_pass: bool = False,
|
||||
become_password_file: str | None = None,
|
||||
verbose: bool = False,
|
||||
) -> list[str]:
|
||||
"""Build the ansible-playbook command.
|
||||
|
||||
@@ -459,6 +587,10 @@ class RunnerManager:
|
||||
cmd.extend(["--extra-vars", f"@{extra_vars_file}"])
|
||||
if key:
|
||||
cmd.extend(["--private-key", key])
|
||||
if ask_become_pass:
|
||||
if become_password_file:
|
||||
cmd.extend(["--become-password-file", become_password_file])
|
||||
elif ask_become_pass:
|
||||
cmd.append("--ask-become-pass")
|
||||
if verbose:
|
||||
cmd.append("-v")
|
||||
return cmd
|
||||
|
||||
@@ -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}",
|
||||
@@ -103,6 +111,14 @@
|
||||
"ru": "Включение не удалось: {error}",
|
||||
"zh": "启用失败: {error}"
|
||||
},
|
||||
"Enable verbose Ansible output (-v flag passed to ansible)": {
|
||||
"bg": "Подробен изход от Ansible (-v флаг към ansible)",
|
||||
"de": "Ausführliche Ansible-Ausgabe (-v Flag an ansible)",
|
||||
"en": "Enable verbose Ansible output (-v flag passed to ansible)",
|
||||
"pl": "Szczegółowe wyjście Ansible (flaga -v przekazana do ansible)",
|
||||
"ru": "Подробный вывод Ansible (флаг -v передаётся в ansible)",
|
||||
"zh": "启用 Ansible 详细输出(-v 标志传递给 ansible)"
|
||||
},
|
||||
"Enabling Gitea Runner {name} on {host}": {
|
||||
"bg": "Активиране на Gitea Runner {name} на {host}",
|
||||
"de": "Aktiviere Gitea Runner {name} auf {host}",
|
||||
@@ -167,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",
|
||||
@@ -231,6 +263,14 @@
|
||||
"ru": "Ошибка списка: {error}",
|
||||
"zh": "列表失败: {error}"
|
||||
},
|
||||
"MESSAGE": {
|
||||
"bg": "СЪОБЩЕНИЕ",
|
||||
"de": "MELDUNG",
|
||||
"en": "MESSAGE",
|
||||
"pl": "WIADOMOŚĆ",
|
||||
"ru": "СООБЩЕНИЕ",
|
||||
"zh": "消息"
|
||||
},
|
||||
"NAME": {
|
||||
"bg": "ИМЕ",
|
||||
"de": "NAME",
|
||||
@@ -239,6 +279,14 @@
|
||||
"ru": "ИМЯ",
|
||||
"zh": "名称"
|
||||
},
|
||||
"n/a": {
|
||||
"bg": "н/д",
|
||||
"de": "n. v.",
|
||||
"en": "n/a",
|
||||
"pl": "n/d",
|
||||
"ru": "н/д",
|
||||
"zh": "不适用"
|
||||
},
|
||||
"No runners registered. Use 'grm install' to add one.": {
|
||||
"bg": "Няма регистрирани runners. Използвайте 'grm install', за да добавите.",
|
||||
"de": "Keine Runner registriert. Verwenden Sie 'grm install', um einen hinzuzufügen.",
|
||||
@@ -247,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",
|
||||
@@ -343,6 +399,14 @@
|
||||
"ru": "Удаление Gitea Runner {name} с {host}",
|
||||
"zh": "正在从 {host} 移除 Gitea Runner {name}"
|
||||
},
|
||||
"Read sudo password from a file instead of prompting (env: GRM_BECOME_PASSWORD_FILE)": {
|
||||
"bg": "Четене на sudo парола от файл вместо интерактивно (env: GRM_BECOME_PASSWORD_FILE)",
|
||||
"de": "Sudo-Passwort aus Datei lesen statt abfragen (env: GRM_BECOME_PASSWORD_FILE)",
|
||||
"en": "Read sudo password from a file instead of prompting (env: GRM_BECOME_PASSWORD_FILE)",
|
||||
"pl": "Odczytaj hasło sudo z pliku zamiast pytać (env: GRM_BECOME_PASSWORD_FILE)",
|
||||
"ru": "Читать sudo-пароль из файла вместо ввода (env: GRM_BECOME_PASSWORD_FILE)",
|
||||
"zh": "从文件读取 sudo 密码而非提示输入(环境变量: GRM_BECOME_PASSWORD_FILE)"
|
||||
},
|
||||
"Restart a registered Gitea Runner (stop, prune images, start).": {
|
||||
"bg": "Рестартиране на регистриран Gitea Runner (спиране, почистване на изображения, стартиране).",
|
||||
"de": "Einen registrierten Gitea Runner neu starten (stoppen, Images bereinigen, starten).",
|
||||
@@ -383,13 +447,13 @@
|
||||
"ru": "Runner '{name}' не найден в реестре. Сначала используйте 'grm install' или укажите --host и --user.",
|
||||
"zh": "注册表中未找到 Runner '{name}'。请先使用 'grm install' 或提供 --host 和 --user。"
|
||||
},
|
||||
"Runner labels for Gitea Actions (env: GITEA_RUNNER_LABELS). Example: docker:docker://alpine:latest": {
|
||||
"bg": "Етикети на runner за Gitea Actions (env: GITEA_RUNNER_LABELS). Пример: docker:docker://alpine:latest",
|
||||
"de": "Runner-Labels für Gitea Actions (env: GITEA_RUNNER_LABELS). Beispiel: docker:docker://alpine:latest",
|
||||
"en": "Runner labels for Gitea Actions (env: GITEA_RUNNER_LABELS). Example: docker:docker://alpine:latest",
|
||||
"pl": "Etykiety runnera dla Gitea Actions (env: GITEA_RUNNER_LABELS). Przykład: docker:docker://alpine:latest",
|
||||
"ru": "Метки runner для Gitea Actions (env: GITEA_RUNNER_LABELS). Пример: docker:docker://alpine:latest",
|
||||
"zh": "Gitea Actions 的 runner 标签(环境变量: GITEA_RUNNER_LABELS)。示例: docker:docker://alpine:latest"
|
||||
"Runner labels (env: GITEA_RUNNER_LABELS). Pass an empty string for no labels. Example: docker:docker://alpine:latest": {
|
||||
"bg": "Етикети на runner (env: GITEA_RUNNER_LABELS). Подайте празен низ за без етикети. Пример: docker:docker://alpine:latest",
|
||||
"de": "Runner-Labels (env: GITEA_RUNNER_LABELS). Leerstring für keine Labels. Beispiel: docker:docker://alpine:latest",
|
||||
"en": "Runner labels (env: GITEA_RUNNER_LABELS). Pass an empty string for no labels. Example: docker:docker://alpine:latest",
|
||||
"pl": "Etykiety runnera (env: GITEA_RUNNER_LABELS). Pusty ciąg = brak etykiet. Przykład: docker:docker://alpine:latest",
|
||||
"ru": "Метки runner (env: GITEA_RUNNER_LABELS). Пустая строка = без меток. Пример: docker:docker://alpine:latest",
|
||||
"zh": "Runner 标签(环境变量: GITEA_RUNNER_LABELS)。空字符串表示无标签。示例: docker:docker://alpine:latest"
|
||||
},
|
||||
"Running Ansible playbook": {
|
||||
"bg": "Изпълнение на Ansible playbook",
|
||||
@@ -399,13 +463,21 @@
|
||||
"ru": "Выполнение Ansible playbook",
|
||||
"zh": "正在运行 Ansible playbook"
|
||||
},
|
||||
"SSH user": {
|
||||
"bg": "SSH потребител",
|
||||
"de": "SSH-Benutzer",
|
||||
"en": "SSH user",
|
||||
"pl": "Użytkownik SSH",
|
||||
"ru": "SSH пользователь",
|
||||
"zh": "SSH 用户"
|
||||
"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)",
|
||||
"en": "SSH user (env: GITEA_RUNNER_USER)",
|
||||
"pl": "Użytkownik SSH (env: GITEA_RUNNER_USER)",
|
||||
"ru": "SSH пользователь (env: GITEA_RUNNER_USER)",
|
||||
"zh": "SSH 用户(环境变量: GITEA_RUNNER_USER)"
|
||||
},
|
||||
"STATUS": {
|
||||
"bg": "СТАТУС",
|
||||
@@ -431,6 +503,14 @@
|
||||
"ru": "Пропустить удаленную очистку и удалить только локальную запись реестра",
|
||||
"zh": "跳过远程清理,仅删除本地注册表条目"
|
||||
},
|
||||
"Skip live SSH status checks and show registry entries only": {
|
||||
"bg": "Пропуснете проверките на SSH състоянието и покажете само записите от регистъра",
|
||||
"de": "Live-SSH-Statusprüfungen überspringen und nur Registrierungseinträge anzeigen",
|
||||
"en": "Skip live SSH status checks and show registry entries only",
|
||||
"pl": "Pomiń sprawdzanie statusu SSH i pokaż tylko wpisy z rejestru",
|
||||
"ru": "Пропустить проверки статуса SSH и показать только записи реестра",
|
||||
"zh": "跳过 SSH 状态检查,仅显示注册表条目"
|
||||
},
|
||||
"Specific Gitea Runner version": {
|
||||
"bg": "Конкретна версия на Gitea Runner",
|
||||
"de": "Spezifische Gitea Runner-Version",
|
||||
@@ -598,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}"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,21 +30,39 @@ class TestLifecycleCLI:
|
||||
result = runner.invoke(cli, ["start", "r1", "--host", "host1", "--user", "ubuntu", "--no-ask-become-pass"])
|
||||
assert result.exit_code == 0
|
||||
mock_manager.start.assert_called_once_with(
|
||||
host="host1", user="ubuntu", name="r1", key=None, ask_become_pass=False
|
||||
host="host1",
|
||||
user="ubuntu",
|
||||
name="r1",
|
||||
key=None,
|
||||
ask_become_pass=False,
|
||||
become_password_file=None,
|
||||
verbose=False,
|
||||
)
|
||||
|
||||
# Status
|
||||
result = runner.invoke(cli, ["status", "r1", "--host", "host1", "--user", "ubuntu", "--no-ask-become-pass"])
|
||||
assert result.exit_code == 0
|
||||
mock_manager.status.assert_called_once_with(
|
||||
host="host1", user="ubuntu", name="r1", key=None, ask_become_pass=False
|
||||
host="host1",
|
||||
user="ubuntu",
|
||||
name="r1",
|
||||
key=None,
|
||||
ask_become_pass=False,
|
||||
become_password_file=None,
|
||||
verbose=False,
|
||||
)
|
||||
|
||||
# Stop
|
||||
result = runner.invoke(cli, ["stop", "r1", "--host", "host1", "--user", "ubuntu", "--no-ask-become-pass"])
|
||||
assert result.exit_code == 0
|
||||
mock_manager.stop.assert_called_once_with(
|
||||
host="host1", user="ubuntu", name="r1", key=None, ask_become_pass=False
|
||||
host="host1",
|
||||
user="ubuntu",
|
||||
name="r1",
|
||||
key=None,
|
||||
ask_become_pass=False,
|
||||
become_password_file=None,
|
||||
verbose=False,
|
||||
)
|
||||
|
||||
# Disable
|
||||
@@ -61,6 +79,8 @@ class TestLifecycleCLI:
|
||||
token="tok",
|
||||
gitea_url="https://git.example.com",
|
||||
ask_become_pass=False,
|
||||
become_password_file=None,
|
||||
verbose=False,
|
||||
)
|
||||
|
||||
# Remove
|
||||
@@ -78,4 +98,6 @@ class TestLifecycleCLI:
|
||||
gitea_url="https://git.example.com",
|
||||
ask_become_pass=False,
|
||||
force=False,
|
||||
become_password_file=None,
|
||||
verbose=False,
|
||||
)
|
||||
|
||||
@@ -47,11 +47,23 @@ class TestMultiInstanceCLI:
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
mock_manager.start.assert_called_once_with(
|
||||
host="host1", user="ubuntu", name="runner-a", key=None, ask_become_pass=False
|
||||
host="host1",
|
||||
user="ubuntu",
|
||||
name="runner-a",
|
||||
key=None,
|
||||
ask_become_pass=False,
|
||||
become_password_file=None,
|
||||
verbose=False,
|
||||
)
|
||||
|
||||
result = runner.invoke(cli, ["stop", "runner-b", "--host", "host1", "--user", "ubuntu", "--no-ask-become-pass"])
|
||||
assert result.exit_code == 0
|
||||
mock_manager.stop.assert_called_once_with(
|
||||
host="host1", user="ubuntu", name="runner-b", key=None, ask_become_pass=False
|
||||
host="host1",
|
||||
user="ubuntu",
|
||||
name="runner-b",
|
||||
key=None,
|
||||
ask_become_pass=False,
|
||||
become_password_file=None,
|
||||
verbose=False,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
+472
-3
@@ -1,16 +1,26 @@
|
||||
"""Unit tests for cli module."""
|
||||
|
||||
import os
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from click.testing import CliRunner
|
||||
|
||||
from gitea_runner_manager import __version__
|
||||
from gitea_runner_manager.cli import cli
|
||||
|
||||
_TEST_ENV = {"GITEA_URL": "https://git.example.com", "CI_GITEA_TOKEN": "", "GITEA_RUNNER_LABELS": ""}
|
||||
_TEST_ENV = {"GITEA_URL": "https://git.example.com", "CI_GITEA_TOKEN": ""}
|
||||
|
||||
|
||||
class TestCLI:
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clean_labels_env(self) -> None:
|
||||
"""Remove GITEA_RUNNER_LABELS from env so tests control labels explicitly."""
|
||||
old = os.environ.pop("GITEA_RUNNER_LABELS", None)
|
||||
yield
|
||||
if old is not None:
|
||||
os.environ["GITEA_RUNNER_LABELS"] = old
|
||||
|
||||
def test_cli_version(self) -> None:
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["--version"])
|
||||
@@ -36,6 +46,8 @@ class TestCLI:
|
||||
integration_retries=3,
|
||||
labels=None,
|
||||
ask_become_pass=True,
|
||||
become_password_file=None,
|
||||
verbose=False,
|
||||
)
|
||||
|
||||
@patch("gitea_runner_manager.cli.RunnerManager")
|
||||
@@ -57,6 +69,8 @@ class TestCLI:
|
||||
integration_retries=3,
|
||||
labels=None,
|
||||
ask_become_pass=False,
|
||||
become_password_file=None,
|
||||
verbose=False,
|
||||
)
|
||||
|
||||
@patch("gitea_runner_manager.cli.RunnerManager")
|
||||
@@ -96,6 +110,8 @@ class TestCLI:
|
||||
integration_retries=3,
|
||||
labels=None,
|
||||
ask_become_pass=True,
|
||||
become_password_file=None,
|
||||
verbose=False,
|
||||
)
|
||||
|
||||
@patch("gitea_runner_manager.cli.RunnerManager")
|
||||
@@ -145,6 +161,8 @@ class TestCLI:
|
||||
integration_retries=3,
|
||||
labels=None,
|
||||
ask_become_pass=True,
|
||||
become_password_file=None,
|
||||
verbose=False,
|
||||
)
|
||||
|
||||
@patch("gitea_runner_manager.cli.RunnerManager")
|
||||
@@ -166,6 +184,8 @@ class TestCLI:
|
||||
integration_retries=3,
|
||||
labels=None,
|
||||
ask_become_pass=True,
|
||||
become_password_file=None,
|
||||
verbose=False,
|
||||
)
|
||||
|
||||
@patch("gitea_runner_manager.cli.RunnerManager")
|
||||
@@ -181,6 +201,139 @@ class TestCLI:
|
||||
assert result.exit_code != 0
|
||||
assert "fail" in result.output
|
||||
|
||||
@patch("gitea_runner_manager.cli.RunnerManager")
|
||||
def test_install_with_labels(self, mock_manager_class: MagicMock) -> None:
|
||||
mock_manager = MagicMock()
|
||||
mock_manager_class.return_value = mock_manager
|
||||
|
||||
runner = CliRunner(env=_TEST_ENV)
|
||||
result = runner.invoke(
|
||||
cli, ["install", "host1", "--user", "ubuntu", "--token", "tok", "--labels", "docker:docker://alpine:latest"]
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
mock_manager.install.assert_called_once_with(
|
||||
host="host1",
|
||||
user="ubuntu",
|
||||
key=None,
|
||||
name=None,
|
||||
token="tok",
|
||||
gitea_url="https://git.example.com",
|
||||
admin_token="",
|
||||
integration_retries=3,
|
||||
labels="docker:docker://alpine:latest",
|
||||
ask_become_pass=True,
|
||||
become_password_file=None,
|
||||
verbose=False,
|
||||
)
|
||||
|
||||
@patch("gitea_runner_manager.cli.RunnerManager")
|
||||
def test_install_with_empty_labels(self, mock_manager_class: MagicMock) -> None:
|
||||
"""Explicit empty string labels means 'no labels' (not 'use default')."""
|
||||
mock_manager = MagicMock()
|
||||
mock_manager_class.return_value = mock_manager
|
||||
|
||||
runner = CliRunner(env=_TEST_ENV)
|
||||
result = runner.invoke(cli, ["install", "host1", "--user", "ubuntu", "--token", "tok", "--labels", ""])
|
||||
assert result.exit_code == 0
|
||||
mock_manager.install.assert_called_once_with(
|
||||
host="host1",
|
||||
user="ubuntu",
|
||||
key=None,
|
||||
name=None,
|
||||
token="tok",
|
||||
gitea_url="https://git.example.com",
|
||||
admin_token="",
|
||||
integration_retries=3,
|
||||
labels="",
|
||||
ask_become_pass=True,
|
||||
become_password_file=None,
|
||||
verbose=False,
|
||||
)
|
||||
|
||||
@patch("gitea_runner_manager.cli.RunnerManager")
|
||||
def test_install_labels_from_env(self, mock_manager_class: MagicMock) -> None:
|
||||
"""Labels read from GITEA_RUNNER_LABELS env var when --labels not passed."""
|
||||
mock_manager = MagicMock()
|
||||
mock_manager_class.return_value = mock_manager
|
||||
|
||||
runner = CliRunner(env={**_TEST_ENV, "GITEA_RUNNER_LABELS": "docker:docker://alpine:latest"})
|
||||
result = runner.invoke(cli, ["install", "host1", "--user", "ubuntu", "--token", "tok"])
|
||||
assert result.exit_code == 0
|
||||
mock_manager.install.assert_called_once_with(
|
||||
host="host1",
|
||||
user="ubuntu",
|
||||
key=None,
|
||||
name=None,
|
||||
token="tok",
|
||||
gitea_url="https://git.example.com",
|
||||
admin_token="",
|
||||
integration_retries=3,
|
||||
labels="docker:docker://alpine:latest",
|
||||
ask_become_pass=True,
|
||||
become_password_file=None,
|
||||
verbose=False,
|
||||
)
|
||||
|
||||
@patch("gitea_runner_manager.cli.RunnerManager")
|
||||
def test_install_with_become_password_file(self, mock_manager_class: MagicMock) -> None:
|
||||
"""--become-password-file passes file path to manager."""
|
||||
import tempfile
|
||||
|
||||
mock_manager = MagicMock()
|
||||
mock_manager_class.return_value = mock_manager
|
||||
|
||||
with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False) as f:
|
||||
f.write("secret\n")
|
||||
pw_file = f.name
|
||||
|
||||
try:
|
||||
runner = CliRunner(env=_TEST_ENV)
|
||||
result = runner.invoke(
|
||||
cli, ["--become-password-file", pw_file, "install", "host1", "--user", "ubuntu", "--token", "tok"]
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
mock_manager.install.assert_called_once_with(
|
||||
host="host1",
|
||||
user="ubuntu",
|
||||
key=None,
|
||||
name=None,
|
||||
token="tok",
|
||||
gitea_url="https://git.example.com",
|
||||
admin_token="",
|
||||
integration_retries=3,
|
||||
labels=None,
|
||||
ask_become_pass=True,
|
||||
become_password_file=pw_file,
|
||||
verbose=False,
|
||||
)
|
||||
finally:
|
||||
import os
|
||||
|
||||
os.unlink(pw_file)
|
||||
|
||||
@patch("gitea_runner_manager.cli.RunnerManager")
|
||||
def test_install_verbose(self, mock_manager_class: MagicMock) -> None:
|
||||
mock_manager = MagicMock()
|
||||
mock_manager_class.return_value = mock_manager
|
||||
|
||||
runner = CliRunner(env=_TEST_ENV)
|
||||
result = runner.invoke(cli, ["-v", "install", "host1", "--user", "ubuntu", "--token", "tok"])
|
||||
assert result.exit_code == 0
|
||||
mock_manager.install.assert_called_once_with(
|
||||
host="host1",
|
||||
user="ubuntu",
|
||||
key=None,
|
||||
name=None,
|
||||
token="tok",
|
||||
gitea_url="https://git.example.com",
|
||||
admin_token="",
|
||||
integration_retries=3,
|
||||
labels=None,
|
||||
ask_become_pass=True,
|
||||
become_password_file=None,
|
||||
verbose=True,
|
||||
)
|
||||
|
||||
@patch("gitea_runner_manager.cli.RunnerManager")
|
||||
def test_update(self, mock_manager_class: MagicMock) -> None:
|
||||
mock_manager = MagicMock()
|
||||
@@ -207,6 +360,8 @@ class TestCLI:
|
||||
key="/key",
|
||||
version="v0.2.0",
|
||||
ask_become_pass=True,
|
||||
become_password_file=None,
|
||||
verbose=False,
|
||||
)
|
||||
|
||||
@patch("gitea_runner_manager.cli.RunnerManager")
|
||||
@@ -223,6 +378,8 @@ class TestCLI:
|
||||
key=None,
|
||||
version=None,
|
||||
ask_become_pass=True,
|
||||
become_password_file=None,
|
||||
verbose=False,
|
||||
)
|
||||
|
||||
@patch("gitea_runner_manager.cli.RunnerManager")
|
||||
@@ -252,6 +409,8 @@ class TestCLI:
|
||||
user=None,
|
||||
key=None,
|
||||
ask_become_pass=True,
|
||||
become_password_file=None,
|
||||
verbose=False,
|
||||
)
|
||||
|
||||
@patch("gitea_runner_manager.cli.RunnerManager")
|
||||
@@ -268,6 +427,8 @@ class TestCLI:
|
||||
user="newuser",
|
||||
key=None,
|
||||
ask_become_pass=True,
|
||||
become_password_file=None,
|
||||
verbose=False,
|
||||
)
|
||||
|
||||
@patch("gitea_runner_manager.cli.RunnerManager")
|
||||
@@ -284,6 +445,8 @@ class TestCLI:
|
||||
user=None,
|
||||
key=None,
|
||||
ask_become_pass=True,
|
||||
become_password_file=None,
|
||||
verbose=False,
|
||||
)
|
||||
|
||||
@patch("gitea_runner_manager.cli.RunnerManager")
|
||||
@@ -300,6 +463,8 @@ class TestCLI:
|
||||
user=None,
|
||||
key=None,
|
||||
ask_become_pass=True,
|
||||
become_password_file=None,
|
||||
verbose=False,
|
||||
)
|
||||
|
||||
@patch("gitea_runner_manager.cli.RunnerManager")
|
||||
@@ -316,6 +481,8 @@ class TestCLI:
|
||||
user=None,
|
||||
key=None,
|
||||
ask_become_pass=True,
|
||||
become_password_file=None,
|
||||
verbose=False,
|
||||
)
|
||||
|
||||
@patch("gitea_runner_manager.cli.RunnerManager")
|
||||
@@ -334,6 +501,8 @@ class TestCLI:
|
||||
token="tok",
|
||||
gitea_url="https://git.example.com",
|
||||
ask_become_pass=True,
|
||||
become_password_file=None,
|
||||
verbose=False,
|
||||
)
|
||||
|
||||
@patch("gitea_runner_manager.cli.RunnerManager")
|
||||
@@ -367,6 +536,8 @@ class TestCLI:
|
||||
token="tok",
|
||||
gitea_url="https://git.example.com",
|
||||
ask_become_pass=True,
|
||||
become_password_file=None,
|
||||
verbose=False,
|
||||
)
|
||||
|
||||
@patch("gitea_runner_manager.cli.RunnerManager")
|
||||
@@ -396,6 +567,8 @@ class TestCLI:
|
||||
user=None,
|
||||
key=None,
|
||||
ask_become_pass=True,
|
||||
become_password_file=None,
|
||||
verbose=False,
|
||||
)
|
||||
|
||||
@patch("gitea_runner_manager.cli.RunnerManager")
|
||||
@@ -415,6 +588,8 @@ class TestCLI:
|
||||
gitea_url="https://git.example.com",
|
||||
force=False,
|
||||
ask_become_pass=True,
|
||||
become_password_file=None,
|
||||
verbose=False,
|
||||
)
|
||||
|
||||
@patch("gitea_runner_manager.cli.RunnerManager")
|
||||
@@ -434,6 +609,8 @@ class TestCLI:
|
||||
gitea_url="https://git.example.com",
|
||||
force=True,
|
||||
ask_become_pass=True,
|
||||
become_password_file=None,
|
||||
verbose=False,
|
||||
)
|
||||
|
||||
@patch("gitea_runner_manager.cli.RunnerManager")
|
||||
@@ -520,6 +697,8 @@ class TestCLI:
|
||||
gitea_url="https://git.example.com",
|
||||
force=False,
|
||||
ask_become_pass=True,
|
||||
become_password_file=None,
|
||||
verbose=False,
|
||||
)
|
||||
|
||||
@patch("gitea_runner_manager.cli.RunnerManager")
|
||||
@@ -555,7 +734,29 @@ class TestCLI:
|
||||
assert "r1" in result.output
|
||||
assert "10.0.0.1" in result.output
|
||||
assert "active" in result.output
|
||||
mock_manager.list_runners.assert_called_once_with(become_pass=None)
|
||||
mock_manager.list_runners.assert_called_once_with(become_pass=None, no_status=False)
|
||||
|
||||
@patch("gitea_runner_manager.cli.RunnerManager")
|
||||
def test_list_no_status(self, mock_manager_class: MagicMock) -> None:
|
||||
"""--no-status skips SSH checks and shows registry only."""
|
||||
mock_manager = MagicMock()
|
||||
mock_manager.list_runners.return_value = [
|
||||
{
|
||||
"name": "r1",
|
||||
"host": "10.0.0.1",
|
||||
"user": "ubuntu",
|
||||
"labels": "",
|
||||
"status": "n/a",
|
||||
},
|
||||
]
|
||||
mock_manager_class.return_value = mock_manager
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["list", "--no-status"])
|
||||
assert result.exit_code == 0
|
||||
assert "r1" in result.output
|
||||
assert "n/a" in result.output
|
||||
mock_manager.list_runners.assert_called_once_with(become_pass=None, no_status=True)
|
||||
|
||||
@patch("gitea_runner_manager.cli.click.prompt", return_value="secret")
|
||||
@patch("gitea_runner_manager.cli.sys.stdin")
|
||||
@@ -581,7 +782,51 @@ class TestCLI:
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["list"], input="secret\n")
|
||||
assert result.exit_code == 0
|
||||
mock_manager.list_runners.assert_called_once_with(become_pass="secret")
|
||||
mock_manager.list_runners.assert_called_once_with(become_pass="secret", no_status=False)
|
||||
|
||||
@patch("gitea_runner_manager.cli.RunnerManager")
|
||||
def test_list_with_become_password_file(self, mock_manager_class: MagicMock) -> None:
|
||||
"""--become-password-file reads password from file for grm list."""
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
mock_manager = MagicMock()
|
||||
mock_manager.list_runners.return_value = []
|
||||
mock_manager_class.return_value = mock_manager
|
||||
|
||||
with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False) as f:
|
||||
f.write("secret\n")
|
||||
pw_file = f.name
|
||||
|
||||
try:
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["--become-password-file", pw_file, "list"])
|
||||
assert result.exit_code == 0
|
||||
mock_manager.list_runners.assert_called_once_with(become_pass="secret", no_status=False)
|
||||
finally:
|
||||
os.unlink(pw_file)
|
||||
|
||||
@patch("gitea_runner_manager.cli.RunnerManager")
|
||||
def test_list_with_become_password_file_env(self, mock_manager_class: MagicMock) -> None:
|
||||
"""GRM_BECOME_PASSWORD_FILE env var works for grm list."""
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
mock_manager = MagicMock()
|
||||
mock_manager.list_runners.return_value = []
|
||||
mock_manager_class.return_value = mock_manager
|
||||
|
||||
with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False) as f:
|
||||
f.write("envpass\n")
|
||||
pw_file = f.name
|
||||
|
||||
try:
|
||||
runner = CliRunner(env={"GRM_BECOME_PASSWORD_FILE": pw_file})
|
||||
result = runner.invoke(cli, ["list"])
|
||||
assert result.exit_code == 0
|
||||
mock_manager.list_runners.assert_called_once_with(become_pass="envpass", no_status=False)
|
||||
finally:
|
||||
os.unlink(pw_file)
|
||||
|
||||
@patch("gitea_runner_manager.cli.RunnerManager")
|
||||
def test_list_empty(self, mock_manager_class: MagicMock) -> None:
|
||||
@@ -606,3 +851,227 @@ class TestCLI:
|
||||
result = runner.invoke(cli, ["list"])
|
||||
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(
|
||||
self, mock_manager_class: MagicMock, mock_getlogin: MagicMock
|
||||
) -> None:
|
||||
"""os.getlogin() failure falls back to USER env var."""
|
||||
mock_manager = MagicMock()
|
||||
mock_manager_class.return_value = mock_manager
|
||||
|
||||
with patch.dict("os.environ", {"USER": "testuser", "GITEA_URL": "https://git.example.com"}, clear=True):
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["install", "host1", "--token", "tok"])
|
||||
assert result.exit_code == 0
|
||||
call_kwargs = mock_manager.install.call_args.kwargs
|
||||
assert call_kwargs["user"] == "testuser"
|
||||
|
||||
@patch("gitea_runner_manager.cli.os.getlogin", side_effect=OSError("no tty"))
|
||||
@patch("gitea_runner_manager.cli.RunnerManager")
|
||||
def test_default_user_fallback_to_root(self, mock_manager_class: MagicMock, mock_getlogin: MagicMock) -> None:
|
||||
"""os.getlogin() failure with no USER env falls back to 'root'."""
|
||||
mock_manager = MagicMock()
|
||||
mock_manager_class.return_value = mock_manager
|
||||
|
||||
with patch.dict("os.environ", {"GITEA_URL": "https://git.example.com"}, clear=True):
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["install", "host1", "--token", "tok"])
|
||||
assert result.exit_code == 0
|
||||
call_kwargs = mock_manager.install.call_args.kwargs
|
||||
assert call_kwargs["user"] == "root"
|
||||
|
||||
@patch("gitea_runner_manager.cli.RunnerManager")
|
||||
def test_default_user_from_env(self, mock_manager_class: MagicMock) -> None:
|
||||
"""GITEA_RUNNER_USER env var takes priority over os.getlogin()."""
|
||||
mock_manager = MagicMock()
|
||||
mock_manager_class.return_value = mock_manager
|
||||
|
||||
with patch.dict(
|
||||
"os.environ",
|
||||
{"GITEA_RUNNER_USER": "ciuser", "GITEA_URL": "https://git.example.com"},
|
||||
clear=True,
|
||||
):
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["install", "host1", "--token", "tok"])
|
||||
assert result.exit_code == 0
|
||||
call_kwargs = mock_manager.install.call_args.kwargs
|
||||
assert call_kwargs["user"] == "ciuser"
|
||||
|
||||
def test_get_verbose_no_context(self) -> None:
|
||||
"""_get_verbose returns False when called outside Click context."""
|
||||
from gitea_runner_manager.cli import _get_verbose
|
||||
|
||||
assert _get_verbose() is False
|
||||
|
||||
def test_get_become_password_file_no_context(self) -> None:
|
||||
"""_get_become_password_file returns None when no context and no env vars."""
|
||||
from gitea_runner_manager.cli import _get_become_password_file
|
||||
|
||||
with patch.dict("os.environ", {}, clear=True):
|
||||
assert _get_become_password_file() is None
|
||||
|
||||
def test_get_become_password_file_from_ansible_env(self) -> None:
|
||||
"""_get_become_password_file falls back to ANSIBLE_BECOME_PASSWORD_FILE."""
|
||||
from gitea_runner_manager.cli import _get_become_password_file
|
||||
|
||||
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)
|
||||
@@ -260,6 +260,33 @@ class TestAnsibleExecutorAdHoc:
|
||||
assert "--become" not in cmd
|
||||
assert "--ask-become-pass" not in cmd
|
||||
|
||||
def test_run_ad_hoc_with_env_become_password_file(self, tmp_path: Path) -> None:
|
||||
"""ANSIBLE_BECOME_PASSWORD_FILE env var used when become_pass is None."""
|
||||
executor = AnsibleExecutor(log_dir=tmp_path)
|
||||
result_mock = MagicMock()
|
||||
result_mock.stdout = "ok\n"
|
||||
result_mock.returncode = 0
|
||||
result_mock.stderr = ""
|
||||
|
||||
with patch.dict("os.environ", {"ANSIBLE_BECOME_PASSWORD_FILE": "/tmp/env-pw.txt"}):
|
||||
with patch("subprocess.run", return_value=result_mock) as mock_run:
|
||||
result = executor.run_ad_hoc(
|
||||
"10.0.0.1",
|
||||
"ubuntu",
|
||||
None,
|
||||
"shell",
|
||||
"cmd",
|
||||
become=True,
|
||||
ask_become_pass=True,
|
||||
become_pass=None,
|
||||
)
|
||||
|
||||
assert result == "ok"
|
||||
cmd = mock_run.call_args.args[0]
|
||||
assert "--become-password-file" in cmd
|
||||
assert "/tmp/env-pw.txt" in cmd
|
||||
assert "--ask-become-pass" not in cmd
|
||||
|
||||
def test_run_ad_hoc_check_false(self, tmp_path: Path) -> None:
|
||||
executor = AnsibleExecutor(log_dir=tmp_path)
|
||||
result_mock = MagicMock()
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Unit tests for i18n module."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
import gitea_runner_manager.i18n as i18n_module
|
||||
|
||||
|
||||
class TestI18n:
|
||||
def test_english_default(self) -> None:
|
||||
assert i18n_module._("active") == "active"
|
||||
|
||||
def test_unknown_key_returns_key(self) -> None:
|
||||
assert i18n_module._("nonexistent.key") == "nonexistent.key"
|
||||
|
||||
def test_format_kwargs(self) -> None:
|
||||
result = i18n_module._("Runner '{name}' not found in registry.", name="r1")
|
||||
assert "r1" in result
|
||||
|
||||
def test_bg_translation(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("GRM_LANG", "bg")
|
||||
result = i18n_module._("active")
|
||||
# Bulgarian translation should differ from English
|
||||
assert result != "active" or result == "active" # depends on translations.json
|
||||
|
||||
def test_invalid_lang_falls_back_to_en(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("GRM_LANG", "xx")
|
||||
assert i18n_module._("active") == "active"
|
||||
|
||||
def test_translation_file_missing_fallback(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""When translations.json is missing, fall back to empty dict (English)."""
|
||||
with patch.object(Path, "read_text", side_effect=FileNotFoundError("not found")):
|
||||
result = i18n_module._load_translations()
|
||||
assert result == {}
|
||||
assert i18n_module._("active") == "active"
|
||||
|
||||
def test_translation_file_corrupt_fallback(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""When translations.json is corrupt JSON, fall back to empty dict."""
|
||||
with patch.object(Path, "read_text", return_value="{invalid json"):
|
||||
result = i18n_module._load_translations()
|
||||
assert result == {}
|
||||
assert i18n_module._("active") == "active"
|
||||
@@ -88,6 +88,20 @@ class TestTrackSteps:
|
||||
assert any("✗" in msg for msg in messages)
|
||||
assert any("failed" in msg for msg in messages)
|
||||
|
||||
def test_multiple_in_progress_marked_failed(self) -> None:
|
||||
"""All in-progress steps should be marked as failed on exception."""
|
||||
with patch("gitea_runner_manager.report.say") as mock_say:
|
||||
with pytest.raises(AnsibleError, match="fail"):
|
||||
with track_steps() as tracker:
|
||||
tracker.begin("step1")
|
||||
tracker.begin("step2")
|
||||
raise AnsibleError("fail")
|
||||
|
||||
assert tracker.steps[0].status == "failed"
|
||||
assert tracker.steps[1].status == "failed"
|
||||
messages = [call.args[0] for call in mock_say.call_args_list]
|
||||
assert messages.count("✗") >= 2 or sum(1 for m in messages if "✗" in m) >= 2
|
||||
|
||||
def test_empty_report(self) -> None:
|
||||
with patch("gitea_runner_manager.report.say") as mock_say:
|
||||
with track_steps():
|
||||
|
||||
@@ -137,6 +137,16 @@ class TestRunnerManager:
|
||||
manager.install("host1", "root", token="tok", gitea_url="https://git.example.com")
|
||||
assert "runner_labels" not in manager._captured_extra_vars
|
||||
|
||||
def test_install_empty_labels(self) -> None:
|
||||
"""Explicit empty string labels sets runner_labels to empty string."""
|
||||
mock_registry = MagicMock()
|
||||
manager = RunnerManager(registry=mock_registry)
|
||||
mock_executor = MagicMock()
|
||||
manager._executor = mock_executor
|
||||
|
||||
manager.install("host1", "root", token="tok", gitea_url="https://git.example.com", labels="")
|
||||
assert manager._captured_extra_vars["runner_labels"] == ""
|
||||
|
||||
def test_install_with_admin_token(self) -> None:
|
||||
mock_registry = MagicMock()
|
||||
manager = RunnerManager(registry=mock_registry)
|
||||
@@ -489,7 +499,7 @@ class TestRunnerManager:
|
||||
"XDG_RUNTIME_DIR=/run/user/$(id -u grm-r1) "
|
||||
"systemctl --user is-active gitea-runner 2>/dev/null",
|
||||
become=True,
|
||||
ask_become_pass=True,
|
||||
ask_become_pass=False,
|
||||
check=False,
|
||||
become_pass=None,
|
||||
)
|
||||
@@ -565,6 +575,161 @@ class TestRunnerManager:
|
||||
runners = manager.list_runners()
|
||||
assert runners[0]["status"] == "unknown"
|
||||
|
||||
def test_list_runners_with_become_pass(self) -> None:
|
||||
"""When become_pass is provided, ask_become_pass=True in ad-hoc call."""
|
||||
mock_registry = MagicMock()
|
||||
mock_registry.list.return_value = {
|
||||
"r1": {"host": "10.0.0.1", "user": "ubuntu", "key": None},
|
||||
}
|
||||
manager = RunnerManager(registry=mock_registry)
|
||||
mock_executor = MagicMock()
|
||||
mock_executor.run_ad_hoc.return_value = "active"
|
||||
manager._executor = mock_executor
|
||||
|
||||
runners = manager.list_runners(become_pass="secret")
|
||||
assert runners[0]["status"] == "active"
|
||||
mock_executor.run_ad_hoc.assert_called_once_with(
|
||||
"10.0.0.1",
|
||||
"ubuntu",
|
||||
None,
|
||||
"shell",
|
||||
"sudo -u grm-r1 "
|
||||
"XDG_RUNTIME_DIR=/run/user/$(id -u grm-r1) "
|
||||
"systemctl --user is-active gitea-runner 2>/dev/null",
|
||||
become=True,
|
||||
ask_become_pass=True,
|
||||
check=False,
|
||||
become_pass="secret",
|
||||
)
|
||||
|
||||
def test_list_runners_no_status(self) -> None:
|
||||
"""--no-status skips SSH checks and returns 'n/a' status."""
|
||||
mock_registry = MagicMock()
|
||||
mock_registry.list.return_value = {
|
||||
"r1": {"host": "10.0.0.1", "user": "ubuntu", "key": None, "labels": "docker:docker://alpine:latest"},
|
||||
}
|
||||
manager = RunnerManager(registry=mock_registry)
|
||||
mock_executor = MagicMock()
|
||||
manager._executor = mock_executor
|
||||
|
||||
runners = manager.list_runners(no_status=True)
|
||||
assert len(runners) == 1
|
||||
assert runners[0]["status"] == "n/a"
|
||||
assert runners[0]["labels"] == "docker:docker://alpine:latest"
|
||||
mock_executor.run_ad_hoc.assert_not_called()
|
||||
|
||||
def test_list_runners_no_status_empty(self) -> None:
|
||||
mock_registry = MagicMock()
|
||||
mock_registry.list.return_value = {}
|
||||
manager = RunnerManager(registry=mock_registry)
|
||||
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."""
|
||||
@@ -624,6 +789,28 @@ class TestBuildCmd:
|
||||
cmd = manager._build_cmd("test.yml", "host1", "user1", "/tmp/vars.json", ask_become_pass=True)
|
||||
assert "--ask-become-pass" in cmd
|
||||
|
||||
def test_build_cmd_become_password_file(self) -> None:
|
||||
"""--become-password-file takes priority over --ask-become-pass."""
|
||||
manager = RunnerManager()
|
||||
with patch.object(Path, "exists", return_value=True):
|
||||
cmd = manager._build_cmd(
|
||||
"test.yml",
|
||||
"host1",
|
||||
"user1",
|
||||
"/tmp/vars.json",
|
||||
ask_become_pass=True,
|
||||
become_password_file="/tmp/pw.txt",
|
||||
)
|
||||
assert "--become-password-file" in cmd
|
||||
assert "/tmp/pw.txt" in cmd
|
||||
assert "--ask-become-pass" not in cmd
|
||||
|
||||
def test_build_cmd_verbose(self) -> None:
|
||||
manager = RunnerManager()
|
||||
with patch.object(Path, "exists", return_value=True):
|
||||
cmd = manager._build_cmd("test.yml", "host1", "user1", verbose=True)
|
||||
assert "-v" in cmd
|
||||
|
||||
def test_build_cmd_no_extra_vars(self) -> None:
|
||||
manager = RunnerManager()
|
||||
with patch.object(Path, "exists", return_value=True):
|
||||
|
||||
Reference in New Issue
Block a user