GRM-2: refactor: remove dead code and legacy artifacts
This commit is contained in:
@@ -28,3 +28,6 @@ htmlcov/
|
|||||||
# Misc
|
# Misc
|
||||||
*.log
|
*.log
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
activate.sh
|
||||||
|
activate.fish
|
||||||
|
activate.zsh
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
#!/usr/bin/env fish
|
|
||||||
# Activate the Python virtual environment for fish
|
|
||||||
# Usage: source activate.fish
|
|
||||||
|
|
||||||
set -l script_dir (dirname (status --current-filename))
|
|
||||||
source "$script_dir/.venv/bin/activate.fish"
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Activate the Python virtual environment for bash/zsh
|
|
||||||
# Usage: source activate.sh
|
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-${(%):-%x}}")" && pwd)"
|
|
||||||
source "$SCRIPT_DIR/.venv/bin/activate"
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
#!/usr/bin/env zsh
|
|
||||||
# Activate the Python virtual environment for zsh
|
|
||||||
# Usage: source activate.zsh
|
|
||||||
|
|
||||||
0="${ZERO:-${0:#$ZSH_ARGZERO}}"
|
|
||||||
0="${${(M)0:#/*}:-$PWD/$0}"
|
|
||||||
source "${0:A:h}/.venv/bin/activate"
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
log.level = "info"
|
|
||||||
runner.file = ".runner"
|
|
||||||
container.label = "gitea-runner=true"
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
[Unit]
|
|
||||||
Description=Gitea Actions Runner ({{ runner_name }})
|
|
||||||
After=network.target docker.service
|
|
||||||
Requires=docker.service
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=simple
|
|
||||||
ExecStart=/usr/local/bin/act_runner daemon --config /etc/act-runner/config.toml
|
|
||||||
WorkingDirectory=/var/lib/gitea-runner
|
|
||||||
Restart=always
|
|
||||||
RestartSec=5
|
|
||||||
User={{ ansible_user | default('root') }}
|
|
||||||
Group=docker
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
---
|
|
||||||
act_runner_version: "latest"
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Entrypoint for Gitea Runner Manager CLI."""
|
|
||||||
|
|
||||||
from gitea_runner_manager.cli import cli
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
cli()
|
|
||||||
-454
@@ -1,454 +0,0 @@
|
|||||||
# Gitea Runner Manager (GRM) – Complete Project Plan
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Overview
|
|
||||||
|
|
||||||
**Gitea Runner Manager (GRM)** is a lean command‑line tool to automate the installation, configuration, and lifecycle management of Gitea Actions runners on **Arch Linux, Ubuntu (22.04, 24.04, 26.04), and Debian (12, 13)** hosts. It is designed to:
|
|
||||||
|
|
||||||
- Be **simple and focused** – no unnecessary features.
|
|
||||||
- Be **secure** – no hardcoded secrets, uses scoped tokens.
|
|
||||||
- Be **idempotent** – can be run multiple times safely.
|
|
||||||
- Be **flexible** – accepts a plain IP address or hostname, and allows specifying the SSH user and private key.
|
|
||||||
|
|
||||||
GRM provides a unified CLI (`grm.py`) and a `make install` target to:
|
|
||||||
|
|
||||||
- List registered runners in a Gitea instance.
|
|
||||||
- Generate registration tokens.
|
|
||||||
- Install and configure a runner on a remote host (Docker, `act_runner`, systemd service, safe Docker pruning).
|
|
||||||
- Update the `act_runner` binary without losing registration.
|
|
||||||
- (Future) Uninstall a runner.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Key Design Decisions
|
|
||||||
|
|
||||||
| Area | Decision | Rationale |
|
|
||||||
|------|----------|-----------|
|
|
||||||
| Target OS | Arch Linux, Ubuntu 22.04/24.04/26.04, Debian 12/13 | Covers 99% of use cases; avoids complexity. |
|
|
||||||
| Architecture | amd64 only | Hetzner and most cloud providers use x86_64. |
|
|
||||||
| Backup | Lightweight config backup (optional) | Runner state is stored in Gitea; re‑registration is trivial. |
|
|
||||||
| Monitoring | None | Gitea UI shows runner status; manual checks are enough. |
|
|
||||||
| Logging | Systemd `journald` | Sufficient for debugging; no centralised logging needed. |
|
|
||||||
| Pruning | Only runner‑labelled resources | Prevents accidental deletion of unrelated containers. |
|
|
||||||
| Integration tests | Run after installation; fail if not successful | Ensures runner is functional from the start. |
|
|
||||||
| Token storage | `.env` file or `--token` flag | No secrets in code; supports CI/CD. |
|
|
||||||
| Host specification | Plain IP or hostname; SSH user and key overridable | Simplifies inventory management, works with any host. |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Architecture
|
|
||||||
|
|
||||||
GRM consists of three layers:
|
|
||||||
|
|
||||||
1. **CLI (Python)**: User commands, Gitea API interactions, Ansible invocation.
|
|
||||||
2. **Ansible Playbook**: Idempotent installation of runner on target host, adapting to OS distribution.
|
|
||||||
3. **Integration Tests**: Run after installation; verify runner is online in Gitea.
|
|
||||||
|
|
||||||
```text
|
|
||||||
+----------------+ +----------------+ +-----------------+
|
|
||||||
| User / CI | ----> | grm.py CLI | ----> | Gitea API |
|
|
||||||
+----------------+ +----------------+ +-----------------+
|
|
||||||
|
|
|
||||||
v
|
|
||||||
+------------------+
|
|
||||||
| Ansible Playbook |
|
|
||||||
+------------------+
|
|
||||||
|
|
|
||||||
v
|
|
||||||
+------------------+
|
|
||||||
| Remote Host |
|
|
||||||
| (Arch/Ubuntu |
|
|
||||||
| /Debian) |
|
|
||||||
+------------------+
|
|
||||||
|
|
|
||||||
v
|
|
||||||
+------------------+
|
|
||||||
| Integration Tests|
|
|
||||||
| (post-install) |
|
|
||||||
+------------------+
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Project Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
gitea-runner-manager/
|
|
||||||
├── .python-version # 3.11.11
|
|
||||||
├── .env.example # Environment variables template
|
|
||||||
├── .gitignore
|
|
||||||
├── README.md
|
|
||||||
├── LICENSE (GPL-3.0)
|
|
||||||
├── Makefile # Targets: setup, install, update, lint, ansible-lint, test, etc.
|
|
||||||
├── pyproject.toml # Single source for Python dependencies
|
|
||||||
├── setup.py # Minimal setup for editable install
|
|
||||||
├── grm.py # CLI entrypoint
|
|
||||||
├── src/
|
|
||||||
│ └── gitea_runner_manager/
|
|
||||||
│ ├── __init__.py
|
|
||||||
│ ├── cli.py # CLI logic (click commands)
|
|
||||||
│ ├── runner_manager.py # Core logic (API calls, Ansible invocation)
|
|
||||||
│ ├── api_client.py # Gitea API interactions
|
|
||||||
│ └── exceptions.py # Custom exceptions
|
|
||||||
├── tests/
|
|
||||||
│ ├── __init__.py
|
|
||||||
│ ├── unit/
|
|
||||||
│ │ ├── test_runner_manager.py
|
|
||||||
│ │ └── test_api_client.py
|
|
||||||
│ └── integration/
|
|
||||||
│ └── test_provision.py # Integration tests for installation
|
|
||||||
├── ansible/
|
|
||||||
│ ├── requirements.yml # Ansible collections
|
|
||||||
│ ├── install-runner.yml # Main playbook
|
|
||||||
│ ├── update-runner.yml # Update playbook (future)
|
|
||||||
│ ├── inventory.example # Optional static inventory (not required)
|
|
||||||
│ ├── group_vars/
|
|
||||||
│ │ └── all.yml
|
|
||||||
│ └── roles/
|
|
||||||
│ └── gitea-runner/
|
|
||||||
│ ├── tasks/
|
|
||||||
│ │ ├── main.yml
|
|
||||||
│ │ ├── docker.yml # Install Docker (OS-specific)
|
|
||||||
│ │ ├── download_act_runner.yml # Download binary
|
|
||||||
│ │ ├── validate.yml # Validate binary
|
|
||||||
│ │ ├── register.yml # Register with Gitea
|
|
||||||
│ │ ├── config.yml # Create config file
|
|
||||||
│ │ ├── service.yml # Systemd service
|
|
||||||
│ │ ├── prune.yml # Docker prune timer
|
|
||||||
│ │ └── integration_test.yml # Post-install validation
|
|
||||||
│ ├── handlers/
|
|
||||||
│ │ └── main.yml
|
|
||||||
│ ├── templates/
|
|
||||||
│ │ ├── act-runner.service.j2
|
|
||||||
│ │ ├── act-runner-config.toml.j2
|
|
||||||
│ │ ├── docker-prune.service.j2
|
|
||||||
│ │ └── docker-prune.timer.j2
|
|
||||||
│ ├── vars/
|
|
||||||
│ │ └── main.yml
|
|
||||||
│ └── molecule/
|
|
||||||
│ └── default/
|
|
||||||
│ ├── molecule.yml
|
|
||||||
│ ├── converge.yml
|
|
||||||
│ ├── verify.yml
|
|
||||||
│ └── prepare.yml
|
|
||||||
└── .pre-commit-config.yaml # Pre-commit and pre-push hooks
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Python Environment and Dependencies
|
|
||||||
|
|
||||||
- **Python version**: 3.11.11 (managed by pyenv).
|
|
||||||
- **Virtual environment**: Created automatically by `make setup` (or manually with `python -m venv .venv`).
|
|
||||||
|
|
||||||
**Dependencies** (defined in `pyproject.toml`):
|
|
||||||
|
|
||||||
| Type | Packages |
|
|
||||||
|------|----------|
|
|
||||||
| Runtime | `requests`, `python-dotenv`, `click`, `ansible` |
|
|
||||||
| Development | `pytest`, `pytest-cov`, `ruff`, `pyright`, `molecule`, `molecule-docker`, `ansible-lint`, `pre-commit` |
|
|
||||||
|
|
||||||
All dependencies are installed with `make setup` or `pip install -e .[dev]`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Makefile (Complete)
|
|
||||||
|
|
||||||
The `Makefile` provides the following targets:
|
|
||||||
|
|
||||||
| Target | Description |
|
|
||||||
|--------|-------------|
|
|
||||||
| `setup` | Full environment setup: checks pyenv, installs Python dependencies, Ansible collections, and pre‑commit hooks. |
|
|
||||||
| `install` | Installs a runner on a host. **Requires `HOST`**, optional `USER`, `KEY`, `NAME`, `TOKEN`. Example: `make install HOST=192.168.1.10 USER=arch NAME=my-runner` |
|
|
||||||
| `update` | Updates the `act_runner` binary on the specified host (future). |
|
|
||||||
| `lint` | Runs Python linters (`ruff`, `pyright`). |
|
|
||||||
| `ansible-lint` | Runs `ansible-lint` on all playbooks and roles. |
|
|
||||||
| `lint-all` | Runs `lint` and `ansible-lint`. |
|
|
||||||
| `test-unit` | Runs unit tests with coverage. |
|
|
||||||
| `pytest-cov` | Runs unit tests with **100% coverage requirement**. |
|
|
||||||
| `molecule` | Runs Ansible Molecule tests. |
|
|
||||||
| `test-all` | Runs `pytest-cov` and `molecule`. |
|
|
||||||
| `clean` | Removes temporary files and caches. |
|
|
||||||
|
|
||||||
**Example usage**:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
make setup # Initialize development environment
|
|
||||||
|
|
||||||
# Install runner on a host (plain IP) with default user (ansible_user in inventory)
|
|
||||||
make install HOST=192.168.1.10
|
|
||||||
|
|
||||||
# With custom user and SSH private key
|
|
||||||
make install HOST=192.168.1.10 USER=arch KEY=~/.ssh/id_ed25519
|
|
||||||
|
|
||||||
# With custom runner name and token (token auto-generated if omitted)
|
|
||||||
make install HOST=runner.example.com USER=ubuntu NAME=prod-runner
|
|
||||||
|
|
||||||
make ansible-lint # Lint Ansible code
|
|
||||||
make test-all # Run all tests (unit + molecule)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Pre-commit and Pre-push Hooks
|
|
||||||
|
|
||||||
Defined in `.pre-commit-config.yaml`. Hooks run automatically on `git commit` and `git push`.
|
|
||||||
|
|
||||||
| Hook | Stage | Purpose |
|
|
||||||
|------|-------|---------|
|
|
||||||
| `ruff-lint` | commit | Lint Python code |
|
|
||||||
| `ruff-format` | commit | Format Python code |
|
|
||||||
| `pyright` | commit | Type‑check Python code |
|
|
||||||
| `ansible-lint` | commit | Lint Ansible playbooks/roles |
|
|
||||||
| `detect-secrets` | commit | Prevent committing secrets |
|
|
||||||
| `pytest-cov` | push | **100% unit test coverage** |
|
|
||||||
| `test-all` | push | Run all tests (unit + molecule) |
|
|
||||||
|
|
||||||
If any hook fails, the commit or push is blocked.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. CLI – `grm.py`
|
|
||||||
|
|
||||||
The CLI is built with `click` and provides the following commands:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# List all registered runners
|
|
||||||
./grm.py list
|
|
||||||
|
|
||||||
# Generate a new registration token
|
|
||||||
./grm.py token
|
|
||||||
|
|
||||||
# Install and configure a runner on a remote host
|
|
||||||
./grm.py install <host> --user <user> [--key <private_key_path>] [--name <runner_name>] [--token <token>]
|
|
||||||
|
|
||||||
# Update runner binary
|
|
||||||
./grm.py update <host> --user <user> [--key <private_key_path>] [--version <specific_version>]
|
|
||||||
```
|
|
||||||
|
|
||||||
**Options**:
|
|
||||||
- `--user`: SSH user (default: from environment or `ansible_user` in inventory, fallback to `root`).
|
|
||||||
- `--key`: Path to private SSH key (optional, uses default key if not provided).
|
|
||||||
- `--name`: Runner name (default: hostname).
|
|
||||||
- `--token`: Registration token (auto-generated if not provided).
|
|
||||||
|
|
||||||
**Environment**:
|
|
||||||
- Reads `.env` file if present.
|
|
||||||
- Uses `GITEA_URL`, `GITEA_TOKEN`, and optionally `GITEA_RUNNER_USER`, `GITEA_RUNNER_KEY` from environment.
|
|
||||||
|
|
||||||
**Implementation** (`src/gitea_runner_manager/cli.py`):
|
|
||||||
- `list` → calls `api_client.get_runners()`.
|
|
||||||
- `token` → calls `api_client.create_registration_token()`.
|
|
||||||
- `install` → generates token (if not provided), builds an Ansible command with `-i <host>,` and `--user <user>` and `--private-key <key>`.
|
|
||||||
- `update` → similar to install but with the update playbook.
|
|
||||||
|
|
||||||
**Ansible invocation**:
|
|
||||||
```bash
|
|
||||||
ansible-playbook install-runner.yml \
|
|
||||||
-i "<host>," \
|
|
||||||
-u <user> \
|
|
||||||
--private-key <key> \
|
|
||||||
--extra-vars "registration_token=<token> runner_name=<name>"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. Ansible Role – `gitea-runner`
|
|
||||||
|
|
||||||
The role performs the following tasks in order, adapting to the OS distribution using `ansible_facts['os_family']` and `ansible_distribution`.
|
|
||||||
|
|
||||||
### 9.1. `docker.yml` – OS‑specific Docker installation
|
|
||||||
|
|
||||||
- **For Debian/Ubuntu**:
|
|
||||||
- Install `apt-transport-https`, `ca-certificates`, `curl`.
|
|
||||||
- Add Docker GPG key and repository.
|
|
||||||
- Install `docker-ce`, `docker-ce-cli`, `containerd.io`, `docker-compose-plugin`.
|
|
||||||
|
|
||||||
- **For Arch Linux**:
|
|
||||||
- Install `docker`, `docker-compose` using `pacman`.
|
|
||||||
- Ensure the `docker` systemd service is enabled and started.
|
|
||||||
- Add the current user to the `docker` group.
|
|
||||||
|
|
||||||
The playbook detects the OS family and executes the appropriate block.
|
|
||||||
|
|
||||||
### 9.2. `download_act_runner.yml`
|
|
||||||
- Fetches the latest (or specified) `act_runner` binary from Gitea releases.
|
|
||||||
- Extracts it to `/usr/local/bin/act_runner` and sets executable permissions.
|
|
||||||
- Uses `ansible_architecture` to choose the correct binary (`linux_amd64`).
|
|
||||||
|
|
||||||
### 9.3. `validate.yml`
|
|
||||||
- Checks that `/usr/local/bin/act_runner` exists and is executable.
|
|
||||||
- Runs `act_runner --version` to ensure it works.
|
|
||||||
- Checks Docker connectivity (`docker version`).
|
|
||||||
- Sets `runner_validated: true` if all checks pass.
|
|
||||||
|
|
||||||
### 9.4. `config.yml`
|
|
||||||
- Creates `/etc/act-runner/config.toml` with the following content:
|
|
||||||
```toml
|
|
||||||
log.level = "info"
|
|
||||||
runner.file = ".runner"
|
|
||||||
container.label = "gitea-runner=true"
|
|
||||||
```
|
|
||||||
- This ensures all spawned containers are labelled, enabling safe pruning.
|
|
||||||
|
|
||||||
### 9.5. `register.yml`
|
|
||||||
- Ensures work directory (`/var/lib/gitea-runner`) exists.
|
|
||||||
- Runs `act_runner register` with the provided `registration_token`, `runner_name`, `labels`, and `gitea_url`.
|
|
||||||
- Skips registration if `.act_runner` already exists (idempotent).
|
|
||||||
|
|
||||||
### 9.6. `service.yml`
|
|
||||||
- Creates systemd service file `/etc/systemd/system/act-runner-{{ runner_name }}.service`.
|
|
||||||
- Points to the config file with `--config /etc/act-runner/config.toml`.
|
|
||||||
- Enables and starts the service.
|
|
||||||
|
|
||||||
### 9.7. `prune.yml`
|
|
||||||
- Creates systemd service and timer for daily Docker prune:
|
|
||||||
- `docker-prune.service`: runs `docker system prune` and `docker volume prune` with filters for `label=gitea-runner=true` and `until=24h`.
|
|
||||||
- `docker-prune.timer`: triggers daily.
|
|
||||||
- Enables and starts the timer.
|
|
||||||
|
|
||||||
### 9.8. `integration_test.yml`
|
|
||||||
- Waits up to 2 minutes for the runner to appear in the Gitea API.
|
|
||||||
- Checks that the runner status is `"online"`.
|
|
||||||
- Fails the playbook if the runner is not found or not online.
|
|
||||||
- This ensures that the runner is fully functional after installation.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 10. Integration Tests (Detailed)
|
|
||||||
|
|
||||||
After registration and service start, the playbook runs `integration_test.yml`. It uses the Gitea API to verify the runner is online. The test is written in Ansible and uses the `uri` module.
|
|
||||||
|
|
||||||
**Conditions**:
|
|
||||||
- Retry every 10 seconds for up to 12 attempts (2 minutes total).
|
|
||||||
- If the runner is not found or not online, the playbook fails with a clear error message.
|
|
||||||
|
|
||||||
**Why this matters**:
|
|
||||||
- Catches registration failures early.
|
|
||||||
- Ensures the runner can communicate with Gitea.
|
|
||||||
- Prevents deploying a broken runner.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 11. Safe Docker Pruning
|
|
||||||
|
|
||||||
The runner labels all its containers with `gitea-runner=true` (via `container.label` in the config file). The prune service uses `--filter "label=gitea-runner=true"` to ensure it only removes resources created by the runner. This guarantees that other services on the same host are not affected.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 12. Quality Gates
|
|
||||||
|
|
||||||
- **100% unit test coverage** (`make pytest-cov`).
|
|
||||||
- **All linters pass** (ruff, pyright, ansible-lint).
|
|
||||||
- **Molecule tests pass** (role validation in Docker container).
|
|
||||||
- **Integration tests pass** (post‑installation validation).
|
|
||||||
|
|
||||||
These gates are enforced by pre‑push hooks.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 13. Installation & Usage
|
|
||||||
|
|
||||||
### 13.1. Developer Setup
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git clone https://git.oblachno.oblachno.com/oblachno/gitea-runner-manager.git
|
|
||||||
cd gitea-runner-manager
|
|
||||||
pyenv install 3.11.11
|
|
||||||
pyenv local 3.11.11
|
|
||||||
make setup
|
|
||||||
```
|
|
||||||
|
|
||||||
### 13.2. Configure Gitea Credentials
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cp .env.example .env
|
|
||||||
# Edit .env:
|
|
||||||
# GITEA_URL=https://git.oblachno.oblachno.com
|
|
||||||
# GITEA_TOKEN=your-personal-access-token
|
|
||||||
# Optional: GITEA_RUNNER_USER=ubuntu # default SSH user
|
|
||||||
# Optional: GITEA_RUNNER_KEY=~/.ssh/id_rsa
|
|
||||||
```
|
|
||||||
|
|
||||||
The token needs `admin:runner` scope (or `admin` for full management).
|
|
||||||
|
|
||||||
### 13.3. Install a Runner
|
|
||||||
|
|
||||||
Using the CLI (recommended for flexibility):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./grm.py install 192.168.1.10 --user ubuntu --key ~/.ssh/id_ed25519 --name prod-runner
|
|
||||||
```
|
|
||||||
|
|
||||||
Using Make:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
make install HOST=192.168.1.10 USER=ubuntu KEY=~/.ssh/id_ed25519 NAME=prod-runner
|
|
||||||
```
|
|
||||||
|
|
||||||
If `USER` is not provided, the CLI uses the environment variable `GITEA_RUNNER_USER` or falls back to the current local user's username (which may not exist on the remote host – it's better to always specify).
|
|
||||||
|
|
||||||
### 13.4. Verify Runner
|
|
||||||
|
|
||||||
Check Gitea admin UI under **Actions → Runners**. The runner should appear as **Online**.
|
|
||||||
|
|
||||||
### 13.5. Update Runner Binary (Future)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./grm.py update 192.168.1.10 --user ubuntu
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 14. Molecule Tests
|
|
||||||
|
|
||||||
The Ansible role is tested with Molecule using a systemd‑enabled Docker container. For Arch Linux, we may use a different Docker image (e.g., `archlinux/archlinux`). The test suite will include scenarios for Ubuntu, Debian, and Arch Linux.
|
|
||||||
|
|
||||||
The `default` scenario:
|
|
||||||
- Verifies Docker installation.
|
|
||||||
- Checks that `act_runner` binary is present and executable.
|
|
||||||
- Asserts that the systemd service is enabled and running.
|
|
||||||
- Ensures the prune timer is active.
|
|
||||||
- Runs the `integration_test` task with a mock Gitea API (or skips it if `runner_register=false`).
|
|
||||||
|
|
||||||
Molecule tests run as part of `make test-all`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 15. Logging
|
|
||||||
|
|
||||||
- All Ansible output goes to stdout (visible in CLI).
|
|
||||||
- `act_runner` logs go to `journald` via the systemd service.
|
|
||||||
- To view runner logs: `sudo journalctl -u act-runner-<name> -f`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 16. Future Extensions (Optional)
|
|
||||||
|
|
||||||
- **Uninstall**: A playbook to stop the service, remove the binary, and delete the work directory.
|
|
||||||
- **Version pinning**: Allow specifying a particular `act_runner` version via CLI.
|
|
||||||
- **Additional distributions**: Extend the role to support more distros if needed.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 17. Success Criteria
|
|
||||||
|
|
||||||
- [ ] `make setup` configures the development environment.
|
|
||||||
- [ ] `make install HOST=... USER=...` provisions a runner on Ubuntu, Debian, and Arch Linux.
|
|
||||||
- [ ] Integration tests pass after installation; installation fails if they do not.
|
|
||||||
- [ ] Pre‑commit and pre‑push hooks enforce quality gates (100% coverage, linting).
|
|
||||||
- [ ] Molecule tests pass for all supported OS.
|
|
||||||
- [ ] Docker prune only affects runner‑labelled resources.
|
|
||||||
- [ ] `make ansible-lint` runs successfully.
|
|
||||||
- [ ] Documentation is complete and accurate.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 18. License
|
|
||||||
|
|
||||||
- **GPL‑3.0** – open source, free to use and modify.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**This GRM plan is production‑ready, lean, cross‑distribution, and flexible.** It supports Arch, Ubuntu, and Debian, and accepts plain IP addresses with configurable SSH user and key. All components are specified, and the `make setup` command gets a developer from zero to a fully configured environment in minutes.
|
|
||||||
@@ -20,6 +20,9 @@ dependencies = [
|
|||||||
"ansible>=14.0.0",
|
"ansible>=14.0.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
grm = "gitea_runner_manager.cli:cli"
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
dev = [
|
dev = [
|
||||||
"pytest>=9.1.0",
|
"pytest>=9.1.0",
|
||||||
|
|||||||
@@ -1,76 +0,0 @@
|
|||||||
"""Gitea API client for runner management."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import os
|
|
||||||
import time
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
import requests
|
|
||||||
|
|
||||||
from .exceptions import GiteaAPIError, RunnerNotFoundError
|
|
||||||
|
|
||||||
|
|
||||||
class GiteaAPIClient:
|
|
||||||
"""Client for interacting with the Gitea API."""
|
|
||||||
|
|
||||||
def __init__(self, base_url: str, token: str) -> None:
|
|
||||||
self.base_url = base_url.rstrip("/")
|
|
||||||
self.token = token
|
|
||||||
self.session = requests.Session()
|
|
||||||
self.session.headers.update({"Authorization": f"token {token}"})
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_env(cls) -> GiteaAPIClient:
|
|
||||||
"""Create a client from environment variables."""
|
|
||||||
base_url = os.getenv("GITEA_URL", "")
|
|
||||||
token = os.getenv("GITEA_TOKEN", "")
|
|
||||||
if not base_url or not token:
|
|
||||||
raise GiteaAPIError("GITEA_URL and GITEA_TOKEN must be set")
|
|
||||||
return cls(base_url, token)
|
|
||||||
|
|
||||||
def _request(
|
|
||||||
self,
|
|
||||||
method: str,
|
|
||||||
path: str,
|
|
||||||
params: dict[str, Any] | None = None,
|
|
||||||
json: dict[str, Any] | None = None,
|
|
||||||
) -> requests.Response:
|
|
||||||
url = f"{self.base_url}/api/v1{path}"
|
|
||||||
response = self.session.request(method, url, params=params, json=json)
|
|
||||||
if not response.ok:
|
|
||||||
raise GiteaAPIError(
|
|
||||||
f"Gitea API error: {response.status_code} {response.text}",
|
|
||||||
status_code=response.status_code,
|
|
||||||
)
|
|
||||||
return response
|
|
||||||
|
|
||||||
def get_runners(self) -> list[dict[str, Any]]:
|
|
||||||
"""List all registered runners."""
|
|
||||||
response = self._request("GET", "/admin/runners")
|
|
||||||
data: dict[str, Any] = response.json()
|
|
||||||
return data.get("runners", [])
|
|
||||||
|
|
||||||
def create_registration_token(self) -> str:
|
|
||||||
"""Generate a new runner registration token."""
|
|
||||||
response = self._request("POST", "/admin/runners/registration-token")
|
|
||||||
data: dict[str, Any] = response.json()
|
|
||||||
token = data.get("token")
|
|
||||||
if not token:
|
|
||||||
raise GiteaAPIError("No token in response")
|
|
||||||
return token
|
|
||||||
|
|
||||||
def wait_for_runner(self, name: str, timeout: int = 120, interval: int = 10) -> dict[str, Any]:
|
|
||||||
"""Wait for a runner to appear and be online."""
|
|
||||||
elapsed = 0
|
|
||||||
effective_interval = max(interval, 1)
|
|
||||||
while elapsed < timeout:
|
|
||||||
runners = self.get_runners()
|
|
||||||
for runner in runners:
|
|
||||||
if runner.get("name") == name:
|
|
||||||
if runner.get("status") == "online":
|
|
||||||
return runner
|
|
||||||
raise RunnerNotFoundError(f"Runner '{name}' is not online")
|
|
||||||
time.sleep(effective_interval)
|
|
||||||
elapsed += effective_interval
|
|
||||||
raise RunnerNotFoundError(f"Runner '{name}' did not appear within {timeout}s")
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
"""Tests for Gitea Runner Manager."""
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
"""Integration tests."""
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
"""Integration tests for runner provisioning."""
|
|
||||||
|
|
||||||
from unittest.mock import MagicMock, patch
|
|
||||||
|
|
||||||
from gitea_runner_manager.runner_manager import RunnerManager
|
|
||||||
|
|
||||||
|
|
||||||
class TestProvisionIntegration:
|
|
||||||
def test_full_install_flow(self) -> None:
|
|
||||||
api = MagicMock()
|
|
||||||
api.base_url = "https://git.example.com"
|
|
||||||
api.create_registration_token.return_value = "test-token"
|
|
||||||
manager = RunnerManager(api)
|
|
||||||
|
|
||||||
with patch("subprocess.run") as mock_run:
|
|
||||||
mock_run.return_value = MagicMock(returncode=0)
|
|
||||||
manager.install("192.168.1.10", "ubuntu", name="integration-runner")
|
|
||||||
mock_run.assert_called_once()
|
|
||||||
cmd = mock_run.call_args[0][0]
|
|
||||||
assert "install-runner.yml" in " ".join(cmd)
|
|
||||||
assert "registration_token=test-token" in " ".join(cmd)
|
|
||||||
assert "runner_name=integration-runner" in " ".join(cmd)
|
|
||||||
|
|
||||||
def test_update_flow(self) -> None:
|
|
||||||
api = MagicMock()
|
|
||||||
manager = RunnerManager(api)
|
|
||||||
|
|
||||||
with patch("subprocess.run") as mock_run:
|
|
||||||
mock_run.return_value = MagicMock(returncode=0)
|
|
||||||
manager.update("192.168.1.10", "ubuntu")
|
|
||||||
mock_run.assert_called_once()
|
|
||||||
cmd = mock_run.call_args[0][0]
|
|
||||||
assert "update-runner.yml" in " ".join(cmd)
|
|
||||||
@@ -1,120 +0,0 @@
|
|||||||
"""Unit tests for api_client module."""
|
|
||||||
|
|
||||||
from unittest.mock import MagicMock, patch
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from gitea_runner_manager.api_client import GiteaAPIClient
|
|
||||||
from gitea_runner_manager.exceptions import GiteaAPIError, RunnerNotFoundError
|
|
||||||
|
|
||||||
|
|
||||||
class TestGiteaAPIClient:
|
|
||||||
def test_init(self) -> None:
|
|
||||||
client = GiteaAPIClient("https://git.example.com", "token123")
|
|
||||||
assert client.base_url == "https://git.example.com"
|
|
||||||
assert client.token == "token123"
|
|
||||||
assert client.session.headers["Authorization"] == "token token123"
|
|
||||||
|
|
||||||
def test_init_strips_trailing_slash(self) -> None:
|
|
||||||
client = GiteaAPIClient("https://git.example.com/", "token123")
|
|
||||||
assert client.base_url == "https://git.example.com"
|
|
||||||
|
|
||||||
def test_from_env_success(self) -> None:
|
|
||||||
with patch.dict("os.environ", {"GITEA_URL": "https://git.example.com", "GITEA_TOKEN": "tok"}):
|
|
||||||
client = GiteaAPIClient.from_env()
|
|
||||||
assert client.base_url == "https://git.example.com"
|
|
||||||
assert client.token == "tok"
|
|
||||||
|
|
||||||
def test_from_env_missing_url(self) -> None:
|
|
||||||
with patch.dict("os.environ", {"GITEA_URL": "", "GITEA_TOKEN": "tok"}, clear=True):
|
|
||||||
with pytest.raises(GiteaAPIError, match="GITEA_URL and GITEA_TOKEN must be set"):
|
|
||||||
GiteaAPIClient.from_env()
|
|
||||||
|
|
||||||
def test_from_env_missing_token(self) -> None:
|
|
||||||
with patch.dict("os.environ", {"GITEA_URL": "https://git.example.com", "GITEA_TOKEN": ""}, clear=True):
|
|
||||||
with pytest.raises(GiteaAPIError, match="GITEA_URL and GITEA_TOKEN must be set"):
|
|
||||||
GiteaAPIClient.from_env()
|
|
||||||
|
|
||||||
def test_request_success(self) -> None:
|
|
||||||
client = GiteaAPIClient("https://git.example.com", "token")
|
|
||||||
mock_response = MagicMock()
|
|
||||||
mock_response.ok = True
|
|
||||||
mock_response.status_code = 200
|
|
||||||
with patch.object(client.session, "request", return_value=mock_response) as mock_req:
|
|
||||||
resp = client._request("GET", "/test")
|
|
||||||
assert resp == mock_response
|
|
||||||
mock_req.assert_called_once_with("GET", "https://git.example.com/api/v1/test", params=None, json=None)
|
|
||||||
|
|
||||||
def test_request_failure(self) -> None:
|
|
||||||
client = GiteaAPIClient("https://git.example.com", "token")
|
|
||||||
mock_response = MagicMock()
|
|
||||||
mock_response.ok = False
|
|
||||||
mock_response.status_code = 404
|
|
||||||
mock_response.text = "Not Found"
|
|
||||||
with patch.object(client.session, "request", return_value=mock_response):
|
|
||||||
with pytest.raises(GiteaAPIError, match="404 Not Found") as exc_info:
|
|
||||||
client._request("GET", "/test")
|
|
||||||
assert exc_info.value.status_code == 404
|
|
||||||
|
|
||||||
def test_get_runners(self) -> None:
|
|
||||||
client = GiteaAPIClient("https://git.example.com", "token")
|
|
||||||
mock_response = MagicMock()
|
|
||||||
mock_response.ok = True
|
|
||||||
mock_response.json.return_value = {"runners": [{"id": 1, "name": "r1"}]}
|
|
||||||
with patch.object(client.session, "request", return_value=mock_response):
|
|
||||||
runners = client.get_runners()
|
|
||||||
assert runners == [{"id": 1, "name": "r1"}]
|
|
||||||
|
|
||||||
def test_get_runners_empty(self) -> None:
|
|
||||||
client = GiteaAPIClient("https://git.example.com", "token")
|
|
||||||
mock_response = MagicMock()
|
|
||||||
mock_response.ok = True
|
|
||||||
mock_response.json.return_value = {}
|
|
||||||
with patch.object(client.session, "request", return_value=mock_response):
|
|
||||||
runners = client.get_runners()
|
|
||||||
assert runners == []
|
|
||||||
|
|
||||||
def test_create_registration_token(self) -> None:
|
|
||||||
client = GiteaAPIClient("https://git.example.com", "token")
|
|
||||||
mock_response = MagicMock()
|
|
||||||
mock_response.ok = True
|
|
||||||
mock_response.json.return_value = {"token": "reg-token-123"}
|
|
||||||
with patch.object(client.session, "request", return_value=mock_response):
|
|
||||||
token = client.create_registration_token()
|
|
||||||
assert token == "reg-token-123"
|
|
||||||
|
|
||||||
def test_create_registration_token_no_token(self) -> None:
|
|
||||||
client = GiteaAPIClient("https://git.example.com", "token")
|
|
||||||
mock_response = MagicMock()
|
|
||||||
mock_response.ok = True
|
|
||||||
mock_response.json.return_value = {}
|
|
||||||
with patch.object(client.session, "request", return_value=mock_response):
|
|
||||||
with pytest.raises(GiteaAPIError, match="No token in response"):
|
|
||||||
client.create_registration_token()
|
|
||||||
|
|
||||||
def test_wait_for_runner_success(self) -> None:
|
|
||||||
client = GiteaAPIClient("https://git.example.com", "token")
|
|
||||||
mock_response = MagicMock()
|
|
||||||
mock_response.ok = True
|
|
||||||
mock_response.json.return_value = {"runners": [{"name": "runner1", "status": "online"}]}
|
|
||||||
with patch.object(client.session, "request", return_value=mock_response):
|
|
||||||
runner = client.wait_for_runner("runner1", timeout=5)
|
|
||||||
assert runner["name"] == "runner1"
|
|
||||||
|
|
||||||
def test_wait_for_runner_not_online(self) -> None:
|
|
||||||
client = GiteaAPIClient("https://git.example.com", "token")
|
|
||||||
mock_response = MagicMock()
|
|
||||||
mock_response.ok = True
|
|
||||||
mock_response.json.return_value = {"runners": [{"name": "runner1", "status": "offline"}]}
|
|
||||||
with patch.object(client.session, "request", return_value=mock_response):
|
|
||||||
with pytest.raises(RunnerNotFoundError, match="is not online"):
|
|
||||||
client.wait_for_runner("runner1", timeout=5)
|
|
||||||
|
|
||||||
def test_wait_for_runner_timeout(self) -> None:
|
|
||||||
client = GiteaAPIClient("https://git.example.com", "token")
|
|
||||||
mock_response = MagicMock()
|
|
||||||
mock_response.ok = True
|
|
||||||
mock_response.json.return_value = {"runners": []}
|
|
||||||
with patch.object(client.session, "request", return_value=mock_response):
|
|
||||||
with pytest.raises(RunnerNotFoundError, match="did not appear within"):
|
|
||||||
client.wait_for_runner("runner1", timeout=1, interval=1)
|
|
||||||
Reference in New Issue
Block a user