Compare commits

...
22 Commits
Author SHA1 Message Date
grm-ci-bot f04be9c39b release: v0.11.0 [skip ci] 2026-06-28 11:23:13 +00:00
emil f67dff8458 GRM-111: feat: unified --become-password-file, --verbose, --no-status, labels fix
Post-merge / detect-type (push) Successful in 51s
Post-merge / release (push) Successful in 1m14s
Post-merge / validate-commit-msg (push) Successful in 1m14s
Post-merge / vikunja (push) Successful in 1m14s
Post-merge / badges (push) Successful in 1m25s
Post-merge / sync-wiki (push) Successful in 1m53s
Post-merge / publish (push) Successful in 1m21s
Post-merge / configure-repo (push) Successful in 1m20s
2026-06-28 11:21:11 +00:00
gitea-actions-bot 763f7640af chore: update badge URLs to commit b95f5ede [skip ci] 2026-06-28 02:15:53 +00:00
emil f8eeea611f GRM-110: chore: bump devx dependency to 0.26.0
Post-merge / detect-type (push) Successful in 53s
Post-merge / release (push) Successful in 1m12s
Post-merge / validate-commit-msg (push) Successful in 1m14s
Post-merge / publish (push) Has been skipped
Post-merge / vikunja (push) Successful in 1m22s
Post-merge / badges (push) Successful in 1m27s
Post-merge / configure-repo (push) Successful in 1m9s
Post-merge / sync-wiki (push) Successful in 1m51s
2026-06-28 02:13:28 +00:00
gitea-actions-bot 774bf479ab chore: update badge URLs to commit 63396d99 [skip ci] 2026-06-28 00:25:55 +00:00
emil 844171ee93 GRM-109: chore: bump devx to >=0.25.0, fix pr_review docs
Post-merge / detect-type (push) Successful in 1m0s
Post-merge / release (push) Successful in 59s
Post-merge / validate-commit-msg (push) Successful in 1m8s
Post-merge / sync-wiki (push) Successful in 1m45s
Post-merge / publish (push) Has been skipped
Post-merge / badges (push) Successful in 1m19s
Post-merge / vikunja (push) Successful in 1m8s
Post-merge / configure-repo (push) Successful in 1m7s
2026-06-28 00:22:45 +00:00
gitea-actions-bot 9a5be879a2 chore: update badge URLs to commit e7d92aa8 [skip ci] 2026-06-27 22:30:14 +00:00
emil f24ed4c963 GRM-108: ci: bump devx>=0.23.4 for classification fix and --auto-login on notify_failure
Post-merge / detect-type (push) Successful in 49s
Post-merge / release (push) Successful in 1m9s
Post-merge / validate-commit-msg (push) Successful in 1m13s
Post-merge / publish (push) Has been skipped
Post-merge / vikunja (push) Successful in 1m12s
Post-merge / badges (push) Successful in 1m20s
Post-merge / sync-wiki (push) Successful in 1m42s
Post-merge / configure-repo (push) Successful in 59s
2026-06-27 22:27:57 +00:00
gitea-actions-bot d1e1d05be5 chore: update badge URLs to commit e8fef3da [skip ci] 2026-06-27 20:28:52 +00:00
gitea-actions-bot dbb9bd7108 chore: update badge URLs to commit b316c4a8 [skip ci] 2026-06-27 20:26:55 +00:00
grm-ci-bot f905550aba release: v0.10.3
Post-merge / detect-type (push) Successful in 1m9s
Post-merge / validate-commit-msg (push) Has been skipped
Post-merge / release (push) Has been skipped
Post-merge / sync-wiki (push) Has been skipped
Post-merge / configure-repo (push) Has been skipped
Post-merge / vikunja (push) Has been skipped
Post-merge / publish (push) Has been skipped
Post-merge / badges (push) Successful in 56s
2026-06-27 20:26:33 +00:00
emil cae4e2a860 GRM-107: refactor: use devx Makefile aliases, bump devx>=0.23.0
Post-merge / detect-type (push) Successful in 1m3s
Post-merge / release (push) Successful in 1m8s
Post-merge / validate-commit-msg (push) Successful in 1m9s
Post-merge / vikunja (push) Successful in 1m13s
Post-merge / badges (push) Successful in 1m19s
Post-merge / sync-wiki (push) Successful in 1m44s
Post-merge / publish (push) Successful in 1m3s
Post-merge / configure-repo (push) Successful in 1m18s
2026-06-27 20:24:29 +00:00
gitea-actions-bot efbd24daec chore: update badge URLs to commit fc36a6d7 [skip ci] 2026-06-27 17:55:55 +00:00
emil 9066ef9724 GRM-106: fix: add EXTRAS=ci to all setup-image calls, workflow-level CI_GITEA_TOKEN
Post-merge / detect-type (push) Successful in 49s
Post-merge / release (push) Successful in 1m5s
Post-merge / validate-commit-msg (push) Successful in 1m7s
Post-merge / publish (push) Has been skipped
Post-merge / vikunja (push) Successful in 1m12s
Post-merge / badges (push) Successful in 1m16s
Post-merge / sync-wiki (push) Successful in 1m33s
Post-merge / configure-repo (push) Successful in 57s
2026-06-27 17:53:45 +00:00
gitea-actions-bot 96770a770e chore: update badge URLs to commit 5c7cd006 [skip ci] 2026-06-27 17:41:15 +00:00
emil e753b34788 GRM-105: fix: revert EXTRAS=ci default in setup-image
Post-merge / detect-type (push) Successful in 43s
Post-merge / validate-commit-msg (push) Successful in 1m4s
Post-merge / configure-repo (push) Failing after 1m6s
Post-merge / vikunja (push) Successful in 1m7s
Post-merge / release (push) Successful in 1m12s
Post-merge / publish (push) Has been skipped
Post-merge / sync-wiki (push) Successful in 1m27s
Post-merge / badges (push) Successful in 1m27s
2026-06-27 17:38:59 +00:00
gitea-actions-bot 48422b18e5 chore: update badge URLs to commit 1504e628 [skip ci] 2026-06-27 16:56:13 +00:00
emil 190157cce6 GRM-104: refactor: remove hadolint on-the-fly install workaround
Post-merge / detect-type (push) Successful in 53s
Post-merge / release (push) Successful in 1m3s
Post-merge / validate-commit-msg (push) Successful in 1m10s
Post-merge / publish (push) Has been skipped
Post-merge / vikunja (push) Successful in 1m10s
Post-merge / badges (push) Successful in 1m20s
Post-merge / sync-wiki (push) Successful in 1m43s
Post-merge / configure-repo (push) Successful in 50s
2026-06-27 16:53:57 +00:00
gitea-actions-bot 1d0a082044 chore: update badge URLs to commit 0f5e8e82 [skip ci] 2026-06-27 16:40:47 +00:00
emil 799d36f254 GRM-103: fix: install hadolint on-the-fly in setup-image
Post-merge / detect-type (push) Successful in 54s
Post-merge / release (push) Successful in 1m10s
Post-merge / validate-commit-msg (push) Successful in 1m13s
Post-merge / publish (push) Has been skipped
Post-merge / vikunja (push) Successful in 1m15s
Post-merge / configure-repo (push) Successful in 1m14s
Post-merge / badges (push) Successful in 1m21s
Post-merge / sync-wiki (push) Successful in 1m42s
2026-06-27 16:38:30 +00:00
gitea-actions-bot e0d43b0ed8 chore: update badge URLs to commit 8e0b18b4 [skip ci] 2026-06-27 16:34:11 +00:00
gitea-actions-bot 3189161f61 chore: update badge URLs to commit ed92e075 [skip ci] 2026-06-27 16:32:16 +00:00
35 changed files with 1524 additions and 130 deletions
+7 -1
View File
@@ -34,9 +34,15 @@ 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
+15 -4
View File
@@ -5,6 +5,10 @@ on:
types: [opened, synchronize]
workflow_dispatch:
env:
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
CI_GITEA_USERNAME: ${{ vars.CI_GITEA_USERNAME }}
jobs:
quality:
runs-on: docker
@@ -94,7 +98,7 @@ jobs:
env:
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
CI_GITEA_USERNAME: ${{ vars.CI_GITEA_USERNAME }}
run: make setup-image
run: make setup-image EXTRAS=ci
- name: Detect changed paths
id: detect
env:
@@ -122,7 +126,7 @@ jobs:
env:
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
CI_GITEA_USERNAME: ${{ vars.CI_GITEA_USERNAME }}
run: make setup-image
run: make setup-image EXTRAS=ci
- name: Discover available runners
id: discover
env:
@@ -172,16 +176,23 @@ jobs:
run: |
. .venv/bin/activate
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"
exit 0
fi
echo "$CI_GITEA_TOKEN" | docker login git.oblachno.oblachno.fyi -u "$CI_GITEA_USERNAME" --password-stdin
# shellcheck disable=SC2086 # intentional word splitting for argument expansion
python3 -m devx.molecule.molecule_ci_guard $TEST_PAIRS
env:
GITEA_URL: ${{ github.server_url }}
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
CI_GITEA_USERNAME: ${{ vars.CI_GITEA_USERNAME }}
RUN_ID: ${{ github.run_id }}
JOB_NAME: ${{ github.job }}
MATRIX_INDEX: ${{ matrix.runner-index }}
GITEA_REPOSITORY: ${{ github.repository }}
PYTHONPATH: src
DOCKER_HOST: unix:///var/run/docker.sock
pr-review:
if: github.event_name == 'pull_request'
@@ -197,7 +208,7 @@ jobs:
env:
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
CI_GITEA_USERNAME: ${{ vars.CI_GITEA_USERNAME }}
run: make setup-image
run: make setup-image EXTRAS=ci
- name: Run automated PR review
env:
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
@@ -237,7 +248,7 @@ jobs:
env:
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
CI_GITEA_USERNAME: ${{ vars.CI_GITEA_USERNAME }}
run: make setup-image
run: make setup-image EXTRAS=ci
- name: Squash merge with task ID
env:
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
+9 -5
View File
@@ -30,6 +30,10 @@ on:
push:
branches: [master]
env:
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
CI_GITEA_USERNAME: ${{ vars.CI_GITEA_USERNAME }}
jobs:
detect-type:
runs-on: docker
@@ -45,7 +49,7 @@ jobs:
env:
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
CI_GITEA_USERNAME: ${{ vars.CI_GITEA_USERNAME }}
run: make setup-image
run: make setup-image EXTRAS=ci
- name: Check if this is a release commit
id: check
env:
@@ -68,7 +72,7 @@ jobs:
env:
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
CI_GITEA_USERNAME: ${{ vars.CI_GITEA_USERNAME }}
run: make setup-image
run: make setup-image EXTRAS=ci
- name: Validate latest commit message
env:
PYTHONPATH: src
@@ -180,7 +184,7 @@ jobs:
env:
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
CI_GITEA_USERNAME: ${{ vars.CI_GITEA_USERNAME }}
run: make setup-image
run: make setup-image EXTRAS=ci
- name: Sync documentation to wiki
env:
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
@@ -255,7 +259,7 @@ jobs:
env:
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
CI_GITEA_USERNAME: ${{ vars.CI_GITEA_USERNAME }}
run: make setup-image
run: make setup-image EXTRAS=ci
- name: Update Vikunja task
env:
VIKUNJA_TOKEN: ${{ secrets.VIKUNJA_TOKEN }}
@@ -290,7 +294,7 @@ jobs:
env:
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
CI_GITEA_USERNAME: ${{ vars.CI_GITEA_USERNAME }}
run: make setup-image
run: make setup-image EXTRAS=ci
- name: Ensure branch protection and labels
env:
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
+1 -2
View File
@@ -132,8 +132,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
+19
View File
@@ -2,6 +2,25 @@
All notable changes to this project will be documented in this file.
## [0.11.0] - 2026-06-28
### Features
- Unified --become-password-file, --verbose, --no-status, labels fix
## [0.10.3] - 2026-06-27
### Bug Fixes
- Install hadolint on-the-fly in setup-image
- Revert EXTRAS=ci default in setup-image
- Add EXTRAS=ci to all setup-image calls, workflow-level CI_GITEA_TOKEN
### Refactor
- Remove hadolint on-the-fly install workaround
- Use devx Makefile aliases, bump devx>=0.23.0
## [0.10.2] - 2026-06-27
### Bug Fixes
+12 -4
View File
@@ -15,6 +15,8 @@ DEVX_PYTHON := $(BIN)/python
DEVX_VENV := $(VENV)
DEVX_BIN := $(BIN)
DEVX_COV_PKG := src/gitea_runner_manager
DEVX_TEST_PATHS := tests/ scripts/tests/
DEVX_LINT_PATHS := src/ scripts/ tests/
# Include shared targets from devx package (create-task, create-pr, push-with-pr,
# check-config, workflow-lint, lint-ruff, clean, venv, .env, activate-scripts,
@@ -63,13 +65,16 @@ setup-release: $(VENV)/bin/activate .env configure-gitea-pypi
$(BIN)/python -m devx.tools.setup --bin "$(BIN)" --skip-install --no-ansible-collections --no-pre-commit
# Setup for pre-built image jobs (deps already in image, just link venv + install project)
# Usage: make setup-image (runtime + ci deps, incl devx)
# Usage: make setup-image (runtime deps only, devx from image)
# make setup-image EXTRAS=lint (runtime + lint deps, e.g. ansible-lint)
# make setup-image EXTRAS=ci,lint (runtime + ci + lint deps)
# make setup-image EXTRAS=ci,lint (runtime + ci + lint deps, upgrades devx)
# NOTE: Cannot alias to devx-setup-image because the venv must exist before
# devx.mak can be included (chicken-and-egg). This standalone target creates
# the venv symlink first, then installs the project.
setup-image:
@if [ -d /opt/venv ]; then ln -sf /opt/venv .venv; . .venv/bin/activate; \
if [ -n "$$CI_GITEA_TOKEN" ]; then export PIP_EXTRA_INDEX_URL="https://$$CI_GITEA_USERNAME:$$CI_GITEA_TOKEN@git.oblachno.oblachno.fyi/api/packages/oblachno-oss/pypi/simple/"; fi; \
pip install -e .$(if $(EXTRAS),[$(EXTRAS)],[ci]); \
pip install -e .$(if $(EXTRAS),[$(EXTRAS)],); \
else echo "[setup-image] /opt/venv not found — falling back to setup-ci"; $(MAKE) setup-ci; fi
# Helper: run pip install with Gitea registry configured
@@ -139,7 +144,10 @@ install-tools: devx-install-tools
install-hooks: devx-install-hooks
clean: devx-clean
test-unit: devx-test-unit
pytest-cov: devx-pytest-cov
# Override devx-pytest-cov to cover both src/ and scripts/
pytest-cov:
@$(BIN)/pytest $(DEVX_TEST_PATHS) -v --cov=src/gitea_runner_manager --cov=scripts --cov-report=term-missing --cov-fail-under=100
workflow-lint: devx-workflow-lint
workflow-dryrun: devx-workflow-dryrun
workflow-check: devx-workflow-check
+75 -8
View File
@@ -8,12 +8,12 @@ Each runner runs in an isolated **rootless Docker** environment under a dedicate
[![CI](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions/workflows/ci.yml/badge.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
[![License: GPL-3.0](https://img.shields.io/badge/license-GPL--3.0-blue)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/src/branch/master/LICENSE)
[![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/5c243201a5c2795cb11880ad0f4094f8da623669/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/5c243201a5c2795cb11880ad0f4094f8da623669/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/5c243201a5c2795cb11880ad0f4094f8da623669/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/5c243201a5c2795cb11880ad0f4094f8da623669/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/5c243201a5c2795cb11880ad0f4094f8da623669/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/5c243201a5c2795cb11880ad0f4094f8da623669/python.svg)](https://www.python.org/downloads/)
[![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/b95f5ede1a3eff8df0bfcef313ea5801f692de25/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/b95f5ede1a3eff8df0bfcef313ea5801f692de25/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/b95f5ede1a3eff8df0bfcef313ea5801f692de25/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/b95f5ede1a3eff8df0bfcef313ea5801f692de25/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/b95f5ede1a3eff8df0bfcef313ea5801f692de25/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/b95f5ede1a3eff8df0bfcef313ea5801f692de25/python.svg)](https://www.python.org/downloads/)
## Why GRM?
@@ -143,14 +143,16 @@ 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 list` | List all registered runners with live status |
| `grm list --no-status` | List registered runners without SSH status checks |
| `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 +176,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
+48
View File
@@ -0,0 +1,48 @@
---
- name: Restart Gitea Actions runner (stop, prune images, start)
hosts: all
become: true
vars:
prune_images: true
tasks:
- name: Include systemd availability check
ansible.builtin.include_role:
name: gitea-runner
tasks_from: systemd_check.yml
- name: Resolve runner UID
ansible.builtin.include_role:
name: gitea-runner
tasks_from: resolve_uid.yml
- name: Stop gitea-runner user service
ansible.builtin.command: systemctl --user stop gitea-runner
become: true
become_user: "{{ gitea_runner_service_user }}"
environment:
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}"
when: systemd_available.stat.exists
changed_when: true
- name: Prune stale runner images from rootless Docker
ansible.builtin.command:
cmd: python3 {{ playbook_dir }}/../scripts/prune_runner_images.py
become: true
become_user: "{{ gitea_runner_service_user }}"
environment:
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}"
DOCKER_HOST: "unix:///run/user/{{ gitea_runner_uid }}/docker.sock"
when:
- systemd_available.stat.exists
- prune_images | default(true)
changed_when: true
failed_when: false
- name: Start gitea-runner user service
ansible.builtin.command: systemctl --user start gitea-runner
become: true
become_user: "{{ gitea_runner_service_user }}"
environment:
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}"
when: systemd_available.stat.exists
changed_when: true
@@ -19,7 +19,7 @@
- name: Assert runner user is absent
ansible.builtin.assert:
that:
- user_check.ansible_facts.getent_passwd is none or
- user_check is failed or
gitea_runner_service_user not in (user_check.ansible_facts.getent_passwd | default({}))
fail_msg: "Runner user still exists after removal"
@@ -0,0 +1,25 @@
---
# Resolve runner identity facts for stop/start/status/restart playbooks.
# These playbooks use include_role with tasks_from, which does NOT expose
# role defaults to the playbook's task-level keywords (become_user, etc).
# We set the facts explicitly here so they're available everywhere.
- name: Resolve runner service user
ansible.builtin.set_fact:
gitea_runner_service_user: "{{ gitea_runner_user_prefix | default('grm-') }}{{ runner_name }}"
gitea_runner_base_data_dir: "/var/lib/gitea-runner"
gitea_runner_base_config_dir: "/etc/gitea-runner"
- name: Resolve runner data and config dirs
ansible.builtin.set_fact:
gitea_runner_data_dir: "{{ gitea_runner_base_data_dir }}/{{ runner_name }}"
gitea_runner_config_dir: "{{ gitea_runner_base_config_dir }}/{{ runner_name }}"
- name: Resolve runner service user UID
ansible.builtin.getent:
database: passwd
key: "{{ gitea_runner_service_user }}"
- name: Set runner UID fact
ansible.builtin.set_fact:
gitea_runner_uid: "{{ getent_passwd[gitea_runner_service_user][1] }}"
@@ -20,6 +20,7 @@
- name: Enable lingering for runner user
ansible.builtin.command: loginctl enable-linger {{ gitea_runner_service_user }}
changed_when: not linger_stat.stat.exists
when: systemd_available.stat.exists
- name: Ensure subuid entry for runner user
ansible.builtin.lineinfile:
+8 -3
View File
@@ -9,9 +9,14 @@
name: gitea-runner
tasks_from: systemd_check.yml
- name: Resolve runner UID
ansible.builtin.include_role:
name: gitea-runner
tasks_from: resolve_uid.yml
- name: Check if runner is already registered
ansible.builtin.stat:
path: "{{ gitea_runner_data_dir | default('/var/lib/gitea-runner/' ~ runner_name) }}/.runner"
path: "{{ gitea_runner_data_dir }}/.runner"
register: runner_registered
- name: Include registration if not registered
@@ -25,8 +30,8 @@
- name: Start gitea-runner user service
ansible.builtin.command: systemctl --user start gitea-runner
become: true
become_user: "{{ gitea_runner_service_user | default('grm-' ~ runner_name) }}"
become_user: "{{ gitea_runner_service_user }}"
environment:
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid | default('') }}"
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}"
when: systemd_available.stat.exists
changed_when: true
+8 -3
View File
@@ -9,12 +9,17 @@
name: gitea-runner
tasks_from: systemd_check.yml
- name: Resolve runner UID
ansible.builtin.include_role:
name: gitea-runner
tasks_from: resolve_uid.yml
- name: Check systemd user service status
ansible.builtin.command: systemctl --user is-active gitea-runner
become: true
become_user: "{{ gitea_runner_service_user | default('grm-' ~ runner_name) }}"
become_user: "{{ gitea_runner_service_user }}"
environment:
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid | default('') }}"
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}"
register: service_status
changed_when: false
when: systemd_available.stat.exists
@@ -26,7 +31,7 @@
- name: Check runner registration file
ansible.builtin.stat:
path: "{{ gitea_runner_data_dir | default('/var/lib/gitea-runner/' ~ runner_name) }}/.runner"
path: "{{ gitea_runner_data_dir }}/.runner"
register: runner_file_stat
- name: Report runner registration
+7 -2
View File
@@ -9,11 +9,16 @@
name: gitea-runner
tasks_from: systemd_check.yml
- name: Resolve runner UID
ansible.builtin.include_role:
name: gitea-runner
tasks_from: resolve_uid.yml
- name: Stop gitea-runner user service
ansible.builtin.command: systemctl --user stop gitea-runner
become: true
become_user: "{{ gitea_runner_service_user | default('grm-' ~ runner_name) }}"
become_user: "{{ gitea_runner_service_user }}"
environment:
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid | default('') }}"
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}"
when: systemd_available.stat.exists
changed_when: true
+6 -6
View File
@@ -8,12 +8,12 @@ Each runner runs in an isolated **rootless Docker** environment under a dedicate
[![CI](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions/workflows/ci.yml/badge.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
[![License: GPL-3.0](https://img.shields.io/badge/license-GPL--3.0-blue)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/src/branch/master/LICENSE)
[![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/5c243201a5c2795cb11880ad0f4094f8da623669/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/5c243201a5c2795cb11880ad0f4094f8da623669/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/5c243201a5c2795cb11880ad0f4094f8da623669/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/5c243201a5c2795cb11880ad0f4094f8da623669/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/5c243201a5c2795cb11880ad0f4094f8da623669/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/5c243201a5c2795cb11880ad0f4094f8da623669/python.svg)](https://www.python.org/downloads/)
[![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/b95f5ede1a3eff8df0bfcef313ea5801f692de25/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/b95f5ede1a3eff8df0bfcef313ea5801f692de25/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/b95f5ede1a3eff8df0bfcef313ea5801f692de25/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/b95f5ede1a3eff8df0bfcef313ea5801f692de25/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/b95f5ede1a3eff8df0bfcef313ea5801f692de25/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/b95f5ede1a3eff8df0bfcef313ea5801f692de25/python.svg)](https://www.python.org/downloads/)
## Overview
+6 -6
View File
@@ -35,7 +35,7 @@ ci = [
"build>=1.5.0",
"twine>=6.2.0",
# Reusable CI/CD and dev tools (auto-merge, pr-review, pre-push checks, etc.)
"devx>=0.22.0",
"devx>=0.26.0",
]
# Lint and type-checking tools (quality job)
lint = [
@@ -55,7 +55,7 @@ molecule = [
dev = [
"gitea-runner-manager[ci,lint,molecule]",
# Reusable CI/CD and dev tools (pre-push hooks, create-task, create-pr)
"devx>=0.22.0",
"devx>=0.26.0",
# Non-Python dev dependency: checkmake (Makefile linter)
# Install via: go install github.com/checkmake/checkmake/cmd/checkmake@latest
]
@@ -67,9 +67,9 @@ where = ["src"]
gitea_runner_manager = ["translations.json"]
[tool.pytest.ini_options]
testpaths = ["tests"]
pythonpath = ["src"]
addopts = "--cov=src/gitea_runner_manager --cov-report=term-missing --cov-fail-under=100"
testpaths = ["tests", "scripts/tests"]
pythonpath = ["src", "scripts"]
addopts = "--cov=src/gitea_runner_manager --cov=scripts/prune_runner_images.py --cov-report=term-missing --cov-fail-under=100"
markers = [
"integration: marks tests as integration tests (not counted in coverage)",
]
@@ -100,7 +100,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.
View File
+105
View File
@@ -0,0 +1,105 @@
#!/usr/bin/env python3
"""Prune stale runner images from a rootless Docker daemon.
Usage:
python3 prune_runner_images.py [--dry-run]
Removes all images matching the runner-images pattern from the local
Docker daemon so the runner pulls a fresh :latest on the next job.
Environment variables:
DOCKER_HOST Docker daemon socket (set by caller)
XDG_RUNTIME_DIR Runtime directory (set by caller)
"""
from __future__ import annotations
import argparse
import re
import subprocess # nosec B404
import sys
from collections.abc import Sequence
#: Pattern for images we want to prune (repository:tag format).
IMAGE_PATTERN = re.compile(r"runner-images/(ci-base|ci-quality|ci-full)")
def list_docker_images() -> list[str]:
"""List all images in the local Docker daemon as repository:tag strings.
Returns:
List of ``repository:tag`` strings (excluding ``<none>`` entries).
"""
result = subprocess.run( # nosec B603
["docker", "images", "--format", "{{.Repository}}:{{.Tag}}"],
capture_output=True,
text=True,
check=True,
)
return [line.strip() for line in result.stdout.splitlines() if line.strip() and "<none>" not in line]
def filter_runner_images(images: Sequence[str]) -> list[str]:
"""Filter image list to only runner-images entries.
Args:
images: List of ``repository:tag`` strings.
Returns:
Subset matching the runner-images pattern.
"""
return [img for img in images if IMAGE_PATTERN.search(img)]
def remove_images(images: Sequence[str], dry_run: bool = False) -> list[str]:
"""Remove the given images from the local Docker daemon.
Args:
images: List of ``repository:tag`` strings to remove.
dry_run: If True, print what would be removed but don't execute.
Returns:
List of images that were removed (or would be removed in dry-run).
"""
removed: list[str] = []
for img in images:
if dry_run:
print(f"[dry-run] would remove: {img}")
removed.append(img)
continue
result = subprocess.run( # nosec B603
["docker", "rmi", "-f", img],
capture_output=True,
text=True,
)
if result.returncode == 0:
print(f"removed: {img}")
removed.append(img)
else:
print(f"failed to remove {img}: {result.stderr.strip()}", file=sys.stderr)
return removed
def main(argv: Sequence[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="Prune stale runner images.")
parser.add_argument(
"--dry-run",
action="store_true",
help="Print what would be removed without executing.",
)
args = parser.parse_args(argv)
all_images = list_docker_images()
runner_images = filter_runner_images(all_images)
if not runner_images:
print("no runner images found to prune")
return 0
removed = remove_images(runner_images, dry_run=args.dry_run)
print(f"pruned {len(removed)} image(s)")
return 0
if __name__ == "__main__": # pragma: no cover
sys.exit(main())
View File
+144
View File
@@ -0,0 +1,144 @@
"""Tests for prune_runner_images.py."""
from __future__ import annotations
from unittest.mock import MagicMock, patch
from scripts.prune_runner_images import (
filter_runner_images,
list_docker_images,
main,
remove_images,
)
class TestListDockerImages:
"""Tests for list_docker_images()."""
@patch("scripts.prune_runner_images.subprocess.run")
def test_returns_images_from_docker(self, mock_run: MagicMock) -> None:
mock_run.return_value = MagicMock(
stdout="repo1:tag1\nrepo2:tag2\n",
returncode=0,
)
result = list_docker_images()
assert result == ["repo1:tag1", "repo2:tag2"]
@patch("scripts.prune_runner_images.subprocess.run")
def test_filters_none_entries(self, mock_run: MagicMock) -> None:
mock_run.return_value = MagicMock(
stdout="repo:tag\n<none>:<none>\nother:v1\n",
returncode=0,
)
result = list_docker_images()
assert result == ["repo:tag", "other:v1"]
@patch("scripts.prune_runner_images.subprocess.run")
def test_empty_output(self, mock_run: MagicMock) -> None:
mock_run.return_value = MagicMock(stdout="", returncode=0)
result = list_docker_images()
assert result == []
@patch("scripts.prune_runner_images.subprocess.run")
def test_strips_whitespace(self, mock_run: MagicMock) -> None:
mock_run.return_value = MagicMock(
stdout=" repo:tag \n\n other:v2 \n",
returncode=0,
)
result = list_docker_images()
assert result == ["repo:tag", "other:v2"]
class TestFilterRunnerImages:
"""Tests for filter_runner_images()."""
def test_matches_runner_images(self) -> None:
images = [
"git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-base:latest",
"git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-quality:latest",
"git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-full:latest",
]
result = filter_runner_images(images)
assert len(result) == 3
def test_excludes_non_runner_images(self) -> None:
images = [
"docker.io/library/python:3.12",
"docker.io/library/nginx:latest",
"git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-base:latest",
]
result = filter_runner_images(images)
assert len(result) == 1
assert "ci-base" in result[0]
def test_empty_list(self) -> None:
assert filter_runner_images([]) == []
def test_no_matches(self) -> None:
images = ["python:3.12", "nginx:latest"]
assert filter_runner_images(images) == []
class TestRemoveImages:
"""Tests for remove_images()."""
@patch("scripts.prune_runner_images.subprocess.run")
def test_removes_images(self, mock_run: MagicMock) -> None:
mock_run.return_value = MagicMock(returncode=0, stderr="")
images = ["repo/ci-base:latest", "repo/ci-quality:latest"]
removed = remove_images(images)
assert removed == images
assert mock_run.call_count == 2
@patch("scripts.prune_runner_images.subprocess.run")
def test_dry_run_does_not_call_docker(self, mock_run: MagicMock) -> None:
images = ["repo/ci-base:latest"]
removed = remove_images(images, dry_run=True)
assert removed == images
mock_run.assert_not_called()
@patch("scripts.prune_runner_images.subprocess.run")
def test_failed_removal_not_in_result(self, mock_run: MagicMock) -> None:
mock_run.return_value = MagicMock(returncode=1, stderr="image in use")
images = ["repo/ci-base:latest"]
removed = remove_images(images)
assert removed == []
@patch("scripts.prune_runner_images.subprocess.run")
def test_empty_list(self, mock_run: MagicMock) -> None:
removed = remove_images([])
assert removed == []
mock_run.assert_not_called()
class TestMain:
"""Tests for main()."""
@patch("scripts.prune_runner_images.list_docker_images")
@patch("scripts.prune_runner_images.remove_images")
def test_no_images(self, mock_remove: MagicMock, mock_list: MagicMock) -> None:
mock_list.return_value = []
assert main([]) == 0
mock_remove.assert_not_called()
@patch("scripts.prune_runner_images.list_docker_images")
@patch("scripts.prune_runner_images.remove_images")
def test_with_images(self, mock_remove: MagicMock, mock_list: MagicMock) -> None:
mock_list.return_value = [
"repo/runner-images/ci-base:latest",
"python:3.12",
]
mock_remove.return_value = ["repo/runner-images/ci-base:latest"]
assert main([]) == 0
mock_remove.assert_called_once()
@patch("scripts.prune_runner_images.list_docker_images")
@patch("scripts.prune_runner_images.remove_images")
def test_dry_run_flag(self, mock_remove: MagicMock, mock_list: MagicMock) -> None:
mock_list.return_value = ["repo/runner-images/ci-base:latest"]
mock_remove.return_value = ["repo/runner-images/ci-base:latest"]
assert main(["--dry-run"]) == 0
mock_remove.assert_called_once_with(
["repo/runner-images/ci-base:latest"],
dry_run=True,
)
+1 -1
View File
@@ -1,3 +1,3 @@
"""Gitea Runner Manager — lean CLI for managing Gitea Actions runners."""
__version__ = "0.10.2"
__version__ = "0.11.0"
+155 -26
View File
@@ -4,7 +4,9 @@ from __future__ import annotations
import functools
import os
import sys
from collections.abc import Callable
from pathlib import Path
from typing import Any
import click
@@ -18,6 +20,34 @@ 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(
@@ -49,8 +79,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."))
@@ -58,8 +105,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)"))
@@ -90,14 +137,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,
@@ -107,25 +159,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."))
@@ -133,8 +186,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"))
@@ -158,6 +211,8 @@ def update(
key=key,
version=version,
ask_become_pass=ask_become_pass,
become_password_file=_get_become_password_file(),
verbose=_get_verbose(),
)
@@ -179,6 +234,8 @@ def start(
user=user,
key=key,
ask_become_pass=ask_become_pass,
become_password_file=_get_become_password_file(),
verbose=_get_verbose(),
)
@@ -200,6 +257,31 @@ def stop(
user=user,
key=key,
ask_become_pass=ask_become_pass,
become_password_file=_get_become_password_file(),
verbose=_get_verbose(),
)
@cli.command(help=_("Restart a registered Gitea Runner (stop, prune images, start)."))
@click.argument("runner_name")
@_runner_options
@_handle_errors("Restart failed: {error}")
def restart(
runner_name: str,
host: str | None,
user: str | None,
key: str | None,
ask_become_pass: bool,
) -> None:
manager = RunnerManager()
manager.restart(
name=runner_name,
host=host,
user=user,
key=key,
ask_become_pass=ask_become_pass,
become_password_file=_get_become_password_file(),
verbose=_get_verbose(),
)
@@ -221,6 +303,8 @@ def enable(
user=user,
key=key,
ask_become_pass=ask_become_pass,
become_password_file=_get_become_password_file(),
verbose=_get_verbose(),
)
@@ -257,6 +341,8 @@ def disable(
token=token,
gitea_url=url,
ask_become_pass=ask_become_pass,
become_password_file=_get_become_password_file(),
verbose=_get_verbose(),
)
@@ -278,6 +364,8 @@ def status(
user=user,
key=key,
ask_become_pass=ask_become_pass,
become_password_file=_get_become_password_file(),
verbose=_get_verbose(),
)
@@ -322,14 +410,55 @@ 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.
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():
return (
click.prompt(
_("Sudo password"),
hide_input=True,
default="",
show_default=False,
)
or None
)
return sys.stdin.readline().strip() or None
@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() -> None:
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()
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."))
+36 -10
View File
@@ -2,11 +2,12 @@
from __future__ import annotations
import contextlib
import logging
import os
import re
import subprocess # nosec B404
import sys
import tempfile
from datetime import datetime
from pathlib import Path
@@ -88,8 +89,14 @@ class AnsibleExecutor:
become: bool = False,
ask_become_pass: bool = False,
check: bool = True,
become_pass: str | None = None,
) -> str:
"""Run an Ansible ad-hoc command and return stdout."""
"""Run an Ansible ad-hoc command and return stdout.
When ``become_pass`` is provided, it is passed via a temporary file
(``--become-password-file``) to avoid stdin consumption issues when
running multiple ad-hoc commands in sequence (e.g. ``grm list``).
"""
cmd = [
"ansible",
host,
@@ -104,16 +111,35 @@ class AnsibleExecutor:
cmd.extend(["--private-key", key])
if become:
cmd.append("--become")
if become and ask_become_pass and sys.stdin.isatty():
cmd.append("--ask-become-pass")
password_file: str | None = None
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)
cmd.extend(["--become-password-file", password_file])
else:
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()
proc = subprocess.run( # nosec B603
cmd,
env=env,
capture_output=True,
text=True,
)
try:
proc = subprocess.run( # nosec B603
cmd,
env=env,
capture_output=True,
text=True,
)
finally:
if password_file:
with contextlib.suppress(FileNotFoundError):
os.unlink(password_file)
if check and proc.returncode != 0:
stderr = proc.stderr.strip() if proc.stderr else ""
raise AnsibleError(
+13 -3
View File
@@ -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:
+16 -6
View File
@@ -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,
-1
View File
@@ -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)
+111 -8
View File
@@ -42,9 +42,9 @@ 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):
@@ -59,10 +59,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 +88,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 +107,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 +120,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 +143,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 +161,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 +206,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 +221,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 +233,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 +248,35 @@ 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()
def restart(
self,
name: str,
host: str | None = None,
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)
with track_steps() as tracker:
tracker.begin(_("Restarting Gitea Runner {name} on {host}", name=name, host=actual_host))
self._run_playbook(
"restart-runner.yml",
actual_host,
actual_user,
{"runner_name": name},
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()
@@ -233,6 +287,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)
@@ -246,6 +302,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()
@@ -258,6 +316,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)
@@ -276,6 +336,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()
@@ -286,6 +348,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)
@@ -299,6 +363,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()
@@ -312,6 +378,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.
@@ -338,6 +406,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()
@@ -345,14 +415,38 @@ class RunnerManager:
self._registry.remove(name)
tracker.done()
def list_runners(self) -> list[dict[str, str]]:
"""Return a list of registered runners with live service status."""
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]] = []
for name, info in runners.items():
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)",
@@ -369,10 +463,13 @@ class RunnerManager:
user,
key,
"shell",
f"sudo -u grm-{name} systemctl --user is-active gitea-runner 2>/dev/null",
f"sudo -u grm-{name} "
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,
)
service_status = self._parse_status(stdout)
except AnsibleError:
@@ -405,6 +502,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.
@@ -427,6 +526,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
+86 -14
View File
@@ -103,6 +103,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}",
@@ -239,6 +247,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.",
@@ -295,6 +311,14 @@
"ru": "Запросить пароль sudo (по умолчанию)",
"zh": "提示输入 sudo 密码(默认)"
},
"Prompt for sudo password once for all status checks (default).": {
"bg": "Подканване за sudo парола веднъж за всички проверки на състоянието (по подразбиране).",
"de": "Einmal nach sudo-Passwort für alle Statusprüfungen fragen (Standard).",
"en": "Prompt for sudo password once for all status checks (default).",
"pl": "Zapytaj o hasło sudo raz dla wszystkich sprawdzeń statusu (domyślnie).",
"ru": "Запросить пароль sudo один раз для всех проверок статуса (по умолчанию).",
"zh": "为所有状态检查提示一次 sudo 密码(默认)。"
},
"Registration token (env: GITEA_REGISTRATION_TOKEN)": {
"bg": "Регистрационен токен (env: GITEA_REGISTRATION_TOKEN)",
"de": "Registrierungstoken (env: GITEA_REGISTRATION_TOKEN)",
@@ -335,6 +359,38 @@
"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).",
"en": "Restart a registered Gitea Runner (stop, prune images, start).",
"pl": "Uruchom ponownie zarejestrowanego Gitea Runner (zatrzymaj, wyczyść obrazy, uruchom).",
"ru": "Перезапустить зарегистрированный Gitea Runner (остановить, очистить образы, запустить).",
"zh": "重启已注册的 Gitea Runner(停止、清理镜像、启动)。"
},
"Restart failed: {error}": {
"bg": "Рестартирането неуспешно: {error}",
"de": "Neustart fehlgeschlagen: {error}",
"en": "Restart failed: {error}",
"pl": "Ponowne uruchomienie nie powiodło się: {error}",
"ru": "Перезапуск не удался: {error}",
"zh": "重启失败: {error}"
},
"Restarting Gitea Runner {name} on {host}": {
"bg": "Рестартиране на Gitea Runner {name} на {host}",
"de": "Starte Gitea Runner {name} auf {host} neu",
"en": "Restarting Gitea Runner {name} on {host}",
"pl": "Ponowne uruchamianie Gitea Runner {name} na {host}",
"ru": "Перезапуск Gitea Runner {name} на {host}",
"zh": "正在 {host} 上重启 Gitea Runner {name}"
},
"Runner '{name}' not found in registry.": {
"bg": "Runner '{name}' не е намерен в регистъра.",
"de": "Runner '{name}' nicht in der Registrierung gefunden.",
@@ -351,13 +407,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",
@@ -367,13 +423,13 @@
"ru": "Выполнение Ansible playbook",
"zh": "正在运行 Ansible playbook"
},
"SSH user": {
"bg": "SSH потребител",
"de": "SSH-Benutzer",
"en": "SSH user",
"pl": "Użytkownik SSH",
"ru": "SSH пользователь",
"zh": "SSH 用户"
"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": "СТАТУС",
@@ -399,6 +455,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",
@@ -463,6 +527,14 @@
"ru": "Остановка Gitea Runner {name} на {host}",
"zh": "正在 {host} 上停止 Gitea Runner {name}"
},
"Sudo password": {
"bg": "Sudo парола",
"de": "Sudo-Passwort",
"en": "Sudo password",
"pl": "Hasło sudo",
"ru": "Пароль sudo",
"zh": "Sudo 密码"
},
"USER": {
"bg": "ПОТРЕБИТЕЛ",
"de": "BENUTZER",
+25 -3
View File
@@ -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,
)
+14 -2
View File
@@ -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,
)
+356 -2
View File
@@ -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,26 @@ class TestCLI:
user=None,
key=None,
ask_become_pass=True,
become_password_file=None,
verbose=False,
)
@patch("gitea_runner_manager.cli.RunnerManager")
def test_restart(self, mock_manager_class: MagicMock) -> None:
mock_manager = MagicMock()
mock_manager_class.return_value = mock_manager
runner = CliRunner()
result = runner.invoke(cli, ["restart", "r1"])
assert result.exit_code == 0
mock_manager.restart.assert_called_once_with(
name="r1",
host=None,
user=None,
key=None,
ask_become_pass=True,
become_password_file=None,
verbose=False,
)
@patch("gitea_runner_manager.cli.RunnerManager")
@@ -300,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")
@@ -318,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")
@@ -351,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")
@@ -380,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")
@@ -399,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")
@@ -418,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")
@@ -504,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")
@@ -539,7 +734,99 @@ 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()
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")
def test_collect_become_pass_tty(self, mock_stdin: MagicMock, mock_prompt: MagicMock) -> None:
from gitea_runner_manager.cli import _collect_become_pass
mock_stdin.isatty.return_value = True
assert _collect_become_pass(ask_become_pass=True) == "secret"
@patch("gitea_runner_manager.cli.sys.stdin")
def test_collect_become_pass_no_ask(self, mock_stdin: MagicMock) -> None:
from gitea_runner_manager.cli import _collect_become_pass
mock_stdin.isatty.return_value = True
assert _collect_become_pass(ask_become_pass=False) is None
@patch("gitea_runner_manager.cli.RunnerManager")
def test_list_with_piped_become_pass(self, mock_manager_class: MagicMock) -> None:
mock_manager = MagicMock()
mock_manager.list_runners.return_value = []
mock_manager_class.return_value = mock_manager
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", 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:
@@ -564,3 +851,70 @@ class TestCLI:
result = runner.invoke(cli, ["list"])
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"
+54 -7
View File
@@ -212,17 +212,14 @@ class TestAnsibleExecutorAdHoc:
result_mock.stderr = ""
with patch("subprocess.run", return_value=result_mock) as mock_run:
with patch("sys.stdin.isatty", return_value=True):
result = executor.run_ad_hoc(
"10.0.0.1", "ubuntu", None, "shell", "cmd", become=True, ask_become_pass=True
)
result = executor.run_ad_hoc("10.0.0.1", "ubuntu", None, "shell", "cmd", become=True, ask_become_pass=True)
assert result == "ok"
cmd = mock_run.call_args.args[0]
assert "--become" in cmd
assert "--ask-become-pass" in cmd
def test_run_ad_hoc_ask_become_pass_no_tty(self, tmp_path: Path) -> None:
def test_run_ad_hoc_with_become_pass(self, tmp_path: Path) -> None:
executor = AnsibleExecutor(log_dir=tmp_path)
result_mock = MagicMock()
result_mock.stdout = "ok\n"
@@ -230,14 +227,64 @@ class TestAnsibleExecutorAdHoc:
result_mock.stderr = ""
with patch("subprocess.run", return_value=result_mock) as mock_run:
with patch("sys.stdin.isatty", return_value=False):
with patch("os.unlink"):
result = executor.run_ad_hoc(
"10.0.0.1", "ubuntu", None, "shell", "cmd", become=True, ask_become_pass=True
"10.0.0.1",
"ubuntu",
None,
"shell",
"cmd",
become=True,
ask_become_pass=True,
become_pass="secret",
)
assert result == "ok"
cmd = mock_run.call_args.args[0]
assert "--become" in cmd
assert "--become-password-file" in cmd
assert "--ask-become-pass" not in cmd
def test_run_ad_hoc_ask_become_pass_no_become(self, tmp_path: Path) -> None:
executor = AnsibleExecutor(log_dir=tmp_path)
result_mock = MagicMock()
result_mock.stdout = "ok\n"
result_mock.returncode = 0
result_mock.stderr = ""
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=False, ask_become_pass=True)
assert result == "ok"
cmd = mock_run.call_args.args[0]
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:
+46
View File
@@ -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"
+14
View File
@@ -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():
+100 -2
View File
@@ -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)
@@ -297,6 +307,20 @@ class TestRunnerManager:
assert manager._captured_extra_vars["runner_name"] == "r1"
assert "Stopping Gitea Runner r1 on host" in mock_executor.run.call_args.kwargs["description"]
def test_restart(self) -> None:
mock_registry = MagicMock()
mock_registry.get.return_value = {"host": "host", "user": "user", "key": None}
manager = RunnerManager(registry=mock_registry)
mock_executor = MagicMock()
manager._executor = mock_executor
manager.restart("r1")
cmd = mock_executor.run.call_args.args[0]
cmd_str = " ".join(cmd)
assert "restart-runner.yml" in cmd_str
assert manager._captured_extra_vars["runner_name"] == "r1"
assert "Restarting Gitea Runner r1 on host" in mock_executor.run.call_args.kwargs["description"]
def test_enable(self) -> None:
mock_registry = MagicMock()
mock_registry.get.return_value = {"host": "host", "user": "user", "key": None}
@@ -471,10 +495,13 @@ class TestRunnerManager:
"ubuntu",
"/key",
"shell",
"sudo -u grm-r1 systemctl --user is-active gitea-runner 2>/dev/null",
"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,
ask_become_pass=False,
check=False,
become_pass=None,
)
def test_list_runners_exception(self) -> None:
@@ -548,6 +575,55 @@ 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 TestExtraVarsFile:
"""Tests for the ``_extra_vars_file`` context manager."""
@@ -607,6 +683,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):