GRM-107: refactor: use devx Makefile aliases, bump devx>=0.23.0
This commit is contained in:
@@ -176,16 +176,23 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
. .venv/bin/activate
|
. .venv/bin/activate
|
||||||
if [ -z "$TEST_PAIRS" ]; then exit 0; fi
|
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
|
# shellcheck disable=SC2086 # intentional word splitting for argument expansion
|
||||||
python3 -m devx.molecule.molecule_ci_guard $TEST_PAIRS
|
python3 -m devx.molecule.molecule_ci_guard $TEST_PAIRS
|
||||||
env:
|
env:
|
||||||
GITEA_URL: ${{ github.server_url }}
|
GITEA_URL: ${{ github.server_url }}
|
||||||
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
|
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
|
||||||
|
CI_GITEA_USERNAME: ${{ vars.CI_GITEA_USERNAME }}
|
||||||
RUN_ID: ${{ github.run_id }}
|
RUN_ID: ${{ github.run_id }}
|
||||||
JOB_NAME: ${{ github.job }}
|
JOB_NAME: ${{ github.job }}
|
||||||
MATRIX_INDEX: ${{ matrix.runner-index }}
|
MATRIX_INDEX: ${{ matrix.runner-index }}
|
||||||
GITEA_REPOSITORY: ${{ github.repository }}
|
GITEA_REPOSITORY: ${{ github.repository }}
|
||||||
PYTHONPATH: src
|
PYTHONPATH: src
|
||||||
|
DOCKER_HOST: unix:///var/run/docker.sock
|
||||||
|
|
||||||
pr-review:
|
pr-review:
|
||||||
if: github.event_name == 'pull_request'
|
if: github.event_name == 'pull_request'
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ DEVX_PYTHON := $(BIN)/python
|
|||||||
DEVX_VENV := $(VENV)
|
DEVX_VENV := $(VENV)
|
||||||
DEVX_BIN := $(BIN)
|
DEVX_BIN := $(BIN)
|
||||||
DEVX_COV_PKG := src/gitea_runner_manager
|
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,
|
# Include shared targets from devx package (create-task, create-pr, push-with-pr,
|
||||||
# check-config, workflow-lint, lint-ruff, clean, venv, .env, activate-scripts,
|
# check-config, workflow-lint, lint-ruff, clean, venv, .env, activate-scripts,
|
||||||
@@ -66,6 +68,9 @@ setup-release: $(VENV)/bin/activate .env configure-gitea-pypi
|
|||||||
# Usage: make setup-image (runtime deps only, devx from image)
|
# 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=lint (runtime + lint deps, e.g. ansible-lint)
|
||||||
# make setup-image EXTRAS=ci,lint (runtime + ci + lint deps, upgrades devx)
|
# 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:
|
setup-image:
|
||||||
@if [ -d /opt/venv ]; then ln -sf /opt/venv .venv; . .venv/bin/activate; \
|
@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; \
|
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; \
|
||||||
@@ -139,7 +144,10 @@ install-tools: devx-install-tools
|
|||||||
install-hooks: devx-install-hooks
|
install-hooks: devx-install-hooks
|
||||||
clean: devx-clean
|
clean: devx-clean
|
||||||
test-unit: devx-test-unit
|
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-lint: devx-workflow-lint
|
||||||
workflow-dryrun: devx-workflow-dryrun
|
workflow-dryrun: devx-workflow-dryrun
|
||||||
workflow-check: devx-workflow-check
|
workflow-check: devx-workflow-check
|
||||||
|
|||||||
@@ -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
|
- name: Assert runner user is absent
|
||||||
ansible.builtin.assert:
|
ansible.builtin.assert:
|
||||||
that:
|
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({}))
|
gitea_runner_service_user not in (user_check.ansible_facts.getent_passwd | default({}))
|
||||||
fail_msg: "Runner user still exists after removal"
|
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
|
- name: Enable lingering for runner user
|
||||||
ansible.builtin.command: loginctl enable-linger {{ gitea_runner_service_user }}
|
ansible.builtin.command: loginctl enable-linger {{ gitea_runner_service_user }}
|
||||||
changed_when: not linger_stat.stat.exists
|
changed_when: not linger_stat.stat.exists
|
||||||
|
when: systemd_available.stat.exists
|
||||||
|
|
||||||
- name: Ensure subuid entry for runner user
|
- name: Ensure subuid entry for runner user
|
||||||
ansible.builtin.lineinfile:
|
ansible.builtin.lineinfile:
|
||||||
|
|||||||
@@ -9,9 +9,14 @@
|
|||||||
name: gitea-runner
|
name: gitea-runner
|
||||||
tasks_from: systemd_check.yml
|
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
|
- name: Check if runner is already registered
|
||||||
ansible.builtin.stat:
|
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
|
register: runner_registered
|
||||||
|
|
||||||
- name: Include registration if not registered
|
- name: Include registration if not registered
|
||||||
@@ -25,8 +30,8 @@
|
|||||||
- name: Start gitea-runner user service
|
- name: Start gitea-runner user service
|
||||||
ansible.builtin.command: systemctl --user start gitea-runner
|
ansible.builtin.command: systemctl --user start gitea-runner
|
||||||
become: true
|
become: true
|
||||||
become_user: "{{ gitea_runner_service_user | default('grm-' ~ runner_name) }}"
|
become_user: "{{ gitea_runner_service_user }}"
|
||||||
environment:
|
environment:
|
||||||
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid | default('') }}"
|
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}"
|
||||||
when: systemd_available.stat.exists
|
when: systemd_available.stat.exists
|
||||||
changed_when: true
|
changed_when: true
|
||||||
|
|||||||
@@ -9,12 +9,17 @@
|
|||||||
name: gitea-runner
|
name: gitea-runner
|
||||||
tasks_from: systemd_check.yml
|
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
|
- name: Check systemd user service status
|
||||||
ansible.builtin.command: systemctl --user is-active gitea-runner
|
ansible.builtin.command: systemctl --user is-active gitea-runner
|
||||||
become: true
|
become: true
|
||||||
become_user: "{{ gitea_runner_service_user | default('grm-' ~ runner_name) }}"
|
become_user: "{{ gitea_runner_service_user }}"
|
||||||
environment:
|
environment:
|
||||||
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid | default('') }}"
|
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}"
|
||||||
register: service_status
|
register: service_status
|
||||||
changed_when: false
|
changed_when: false
|
||||||
when: systemd_available.stat.exists
|
when: systemd_available.stat.exists
|
||||||
@@ -26,7 +31,7 @@
|
|||||||
|
|
||||||
- name: Check runner registration file
|
- name: Check runner registration file
|
||||||
ansible.builtin.stat:
|
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
|
register: runner_file_stat
|
||||||
|
|
||||||
- name: Report runner registration
|
- name: Report runner registration
|
||||||
|
|||||||
@@ -9,11 +9,16 @@
|
|||||||
name: gitea-runner
|
name: gitea-runner
|
||||||
tasks_from: systemd_check.yml
|
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
|
- name: Stop gitea-runner user service
|
||||||
ansible.builtin.command: systemctl --user stop gitea-runner
|
ansible.builtin.command: systemctl --user stop gitea-runner
|
||||||
become: true
|
become: true
|
||||||
become_user: "{{ gitea_runner_service_user | default('grm-' ~ runner_name) }}"
|
become_user: "{{ gitea_runner_service_user }}"
|
||||||
environment:
|
environment:
|
||||||
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid | default('') }}"
|
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}"
|
||||||
when: systemd_available.stat.exists
|
when: systemd_available.stat.exists
|
||||||
changed_when: true
|
changed_when: true
|
||||||
|
|||||||
+5
-5
@@ -35,7 +35,7 @@ ci = [
|
|||||||
"build>=1.5.0",
|
"build>=1.5.0",
|
||||||
"twine>=6.2.0",
|
"twine>=6.2.0",
|
||||||
# Reusable CI/CD and dev tools (auto-merge, pr-review, pre-push checks, etc.)
|
# Reusable CI/CD and dev tools (auto-merge, pr-review, pre-push checks, etc.)
|
||||||
"devx>=0.22.0",
|
"devx>=0.23.0",
|
||||||
]
|
]
|
||||||
# Lint and type-checking tools (quality job)
|
# Lint and type-checking tools (quality job)
|
||||||
lint = [
|
lint = [
|
||||||
@@ -55,7 +55,7 @@ molecule = [
|
|||||||
dev = [
|
dev = [
|
||||||
"gitea-runner-manager[ci,lint,molecule]",
|
"gitea-runner-manager[ci,lint,molecule]",
|
||||||
# Reusable CI/CD and dev tools (pre-push hooks, create-task, create-pr)
|
# Reusable CI/CD and dev tools (pre-push hooks, create-task, create-pr)
|
||||||
"devx>=0.22.0",
|
"devx>=0.23.0",
|
||||||
# Non-Python dev dependency: checkmake (Makefile linter)
|
# Non-Python dev dependency: checkmake (Makefile linter)
|
||||||
# Install via: go install github.com/checkmake/checkmake/cmd/checkmake@latest
|
# Install via: go install github.com/checkmake/checkmake/cmd/checkmake@latest
|
||||||
]
|
]
|
||||||
@@ -67,9 +67,9 @@ where = ["src"]
|
|||||||
gitea_runner_manager = ["translations.json"]
|
gitea_runner_manager = ["translations.json"]
|
||||||
|
|
||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
testpaths = ["tests"]
|
testpaths = ["tests", "scripts/tests"]
|
||||||
pythonpath = ["src"]
|
pythonpath = ["src", "scripts"]
|
||||||
addopts = "--cov=src/gitea_runner_manager --cov-report=term-missing --cov-fail-under=100"
|
addopts = "--cov=src/gitea_runner_manager --cov=scripts/prune_runner_images.py --cov-report=term-missing --cov-fail-under=100"
|
||||||
markers = [
|
markers = [
|
||||||
"integration: marks tests as integration tests (not counted in coverage)",
|
"integration: marks tests as integration tests (not counted in coverage)",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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())
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import functools
|
import functools
|
||||||
import os
|
import os
|
||||||
|
import sys
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -203,6 +204,27 @@ def stop(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@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,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@cli.command(help=_("Enable a registered Gitea Runner to start on boot."))
|
@cli.command(help=_("Enable a registered Gitea Runner to start on boot."))
|
||||||
@click.argument("runner_name")
|
@click.argument("runner_name")
|
||||||
@_runner_options
|
@_runner_options
|
||||||
@@ -325,11 +347,39 @@ def remove(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _collect_become_pass(ask_become_pass: bool) -> str | None:
|
||||||
|
"""Collect sudo password for ad-hoc status checks.
|
||||||
|
|
||||||
|
When stdin is a TTY, prompts interactively with hidden input.
|
||||||
|
When stdin is piped, reads the first line (e.g. ``echo 'pass' | grm list``).
|
||||||
|
"""
|
||||||
|
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."))
|
@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)."),
|
||||||
|
)
|
||||||
@_handle_errors("List failed: {error}")
|
@_handle_errors("List failed: {error}")
|
||||||
def list_runners() -> None:
|
def list_runners(ask_become_pass: bool) -> None:
|
||||||
|
become_pass = _collect_become_pass(ask_become_pass)
|
||||||
|
|
||||||
manager = RunnerManager()
|
manager = RunnerManager()
|
||||||
runners = manager.list_runners()
|
runners = manager.list_runners(become_pass=become_pass)
|
||||||
|
|
||||||
if not runners:
|
if not runners:
|
||||||
click.echo(_("No runners registered. Use 'grm install' to add one."))
|
click.echo(_("No runners registered. Use 'grm install' to add one."))
|
||||||
|
|||||||
@@ -2,11 +2,12 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import contextlib
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import subprocess # nosec B404
|
import subprocess # nosec B404
|
||||||
import sys
|
import tempfile
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -88,8 +89,14 @@ class AnsibleExecutor:
|
|||||||
become: bool = False,
|
become: bool = False,
|
||||||
ask_become_pass: bool = False,
|
ask_become_pass: bool = False,
|
||||||
check: bool = True,
|
check: bool = True,
|
||||||
|
become_pass: str | None = None,
|
||||||
) -> str:
|
) -> 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 = [
|
cmd = [
|
||||||
"ansible",
|
"ansible",
|
||||||
host,
|
host,
|
||||||
@@ -104,16 +111,31 @@ class AnsibleExecutor:
|
|||||||
cmd.extend(["--private-key", key])
|
cmd.extend(["--private-key", key])
|
||||||
if become:
|
if become:
|
||||||
cmd.append("--become")
|
cmd.append("--become")
|
||||||
if become and ask_become_pass and sys.stdin.isatty():
|
|
||||||
|
password_file: str | None = None
|
||||||
|
if become and ask_become_pass:
|
||||||
|
if become_pass:
|
||||||
|
fd, password_file = tempfile.mkstemp(suffix=".txt", prefix="grm-become-")
|
||||||
|
with os.fdopen(fd, "w") as f:
|
||||||
|
f.write(become_pass)
|
||||||
|
os.chmod(password_file, 0o600)
|
||||||
|
cmd.extend(["--become-password-file", password_file])
|
||||||
|
else:
|
||||||
cmd.append("--ask-become-pass")
|
cmd.append("--ask-become-pass")
|
||||||
|
|
||||||
env = os.environ.copy()
|
env = os.environ.copy()
|
||||||
|
try:
|
||||||
proc = subprocess.run( # nosec B603
|
proc = subprocess.run( # nosec B603
|
||||||
cmd,
|
cmd,
|
||||||
env=env,
|
env=env,
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
)
|
)
|
||||||
|
finally:
|
||||||
|
if password_file:
|
||||||
|
with contextlib.suppress(FileNotFoundError):
|
||||||
|
os.unlink(password_file)
|
||||||
|
|
||||||
if check and proc.returncode != 0:
|
if check and proc.returncode != 0:
|
||||||
stderr = proc.stderr.strip() if proc.stderr else ""
|
stderr = proc.stderr.strip() if proc.stderr else ""
|
||||||
raise AnsibleError(
|
raise AnsibleError(
|
||||||
|
|||||||
@@ -226,6 +226,29 @@ class RunnerManager:
|
|||||||
)
|
)
|
||||||
tracker.done()
|
tracker.done()
|
||||||
|
|
||||||
|
def restart(
|
||||||
|
self,
|
||||||
|
name: str,
|
||||||
|
host: str | None = None,
|
||||||
|
user: str | None = None,
|
||||||
|
key: str | None = None,
|
||||||
|
ask_become_pass: 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),
|
||||||
|
)
|
||||||
|
tracker.done()
|
||||||
|
|
||||||
def enable(
|
def enable(
|
||||||
self,
|
self,
|
||||||
name: str,
|
name: str,
|
||||||
@@ -345,8 +368,14 @@ class RunnerManager:
|
|||||||
self._registry.remove(name)
|
self._registry.remove(name)
|
||||||
tracker.done()
|
tracker.done()
|
||||||
|
|
||||||
def list_runners(self) -> list[dict[str, str]]:
|
def list_runners(self, become_pass: str | None = None) -> list[dict[str, str]]:
|
||||||
"""Return a list of registered runners with live service status."""
|
"""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.
|
||||||
|
"""
|
||||||
runners = self._registry.list()
|
runners = self._registry.list()
|
||||||
result: list[dict[str, str]] = []
|
result: list[dict[str, str]] = []
|
||||||
for name, info in runners.items():
|
for name, info in runners.items():
|
||||||
@@ -369,10 +398,13 @@ class RunnerManager:
|
|||||||
user,
|
user,
|
||||||
key,
|
key,
|
||||||
"shell",
|
"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,
|
become=True,
|
||||||
ask_become_pass=True,
|
ask_become_pass=True,
|
||||||
check=False,
|
check=False,
|
||||||
|
become_pass=become_pass,
|
||||||
)
|
)
|
||||||
service_status = self._parse_status(stdout)
|
service_status = self._parse_status(stdout)
|
||||||
except AnsibleError:
|
except AnsibleError:
|
||||||
|
|||||||
@@ -295,6 +295,14 @@
|
|||||||
"ru": "Запросить пароль sudo (по умолчанию)",
|
"ru": "Запросить пароль sudo (по умолчанию)",
|
||||||
"zh": "提示输入 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)": {
|
"Registration token (env: GITEA_REGISTRATION_TOKEN)": {
|
||||||
"bg": "Регистрационен токен (env: GITEA_REGISTRATION_TOKEN)",
|
"bg": "Регистрационен токен (env: GITEA_REGISTRATION_TOKEN)",
|
||||||
"de": "Registrierungstoken (env: GITEA_REGISTRATION_TOKEN)",
|
"de": "Registrierungstoken (env: GITEA_REGISTRATION_TOKEN)",
|
||||||
@@ -335,6 +343,30 @@
|
|||||||
"ru": "Удаление Gitea Runner {name} с {host}",
|
"ru": "Удаление Gitea Runner {name} с {host}",
|
||||||
"zh": "正在从 {host} 移除 Gitea Runner {name}"
|
"zh": "正在从 {host} 移除 Gitea Runner {name}"
|
||||||
},
|
},
|
||||||
|
"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.": {
|
"Runner '{name}' not found in registry.": {
|
||||||
"bg": "Runner '{name}' не е намерен в регистъра.",
|
"bg": "Runner '{name}' не е намерен в регистъра.",
|
||||||
"de": "Runner '{name}' nicht in der Registrierung gefunden.",
|
"de": "Runner '{name}' nicht in der Registrierung gefunden.",
|
||||||
@@ -463,6 +495,14 @@
|
|||||||
"ru": "Остановка Gitea Runner {name} на {host}",
|
"ru": "Остановка Gitea Runner {name} на {host}",
|
||||||
"zh": "正在 {host} 上停止 Gitea Runner {name}"
|
"zh": "正在 {host} 上停止 Gitea Runner {name}"
|
||||||
},
|
},
|
||||||
|
"Sudo password": {
|
||||||
|
"bg": "Sudo парола",
|
||||||
|
"de": "Sudo-Passwort",
|
||||||
|
"en": "Sudo password",
|
||||||
|
"pl": "Hasło sudo",
|
||||||
|
"ru": "Пароль sudo",
|
||||||
|
"zh": "Sudo 密码"
|
||||||
|
},
|
||||||
"USER": {
|
"USER": {
|
||||||
"bg": "ПОТРЕБИТЕЛ",
|
"bg": "ПОТРЕБИТЕЛ",
|
||||||
"de": "BENUTZER",
|
"de": "BENUTZER",
|
||||||
|
|||||||
+43
-1
@@ -286,6 +286,22 @@ class TestCLI:
|
|||||||
ask_become_pass=True,
|
ask_become_pass=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@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,
|
||||||
|
)
|
||||||
|
|
||||||
@patch("gitea_runner_manager.cli.RunnerManager")
|
@patch("gitea_runner_manager.cli.RunnerManager")
|
||||||
def test_enable(self, mock_manager_class: MagicMock) -> None:
|
def test_enable(self, mock_manager_class: MagicMock) -> None:
|
||||||
mock_manager = MagicMock()
|
mock_manager = MagicMock()
|
||||||
@@ -539,7 +555,33 @@ class TestCLI:
|
|||||||
assert "r1" in result.output
|
assert "r1" in result.output
|
||||||
assert "10.0.0.1" in result.output
|
assert "10.0.0.1" in result.output
|
||||||
assert "active" 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)
|
||||||
|
|
||||||
|
@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")
|
||||||
|
|
||||||
@patch("gitea_runner_manager.cli.RunnerManager")
|
@patch("gitea_runner_manager.cli.RunnerManager")
|
||||||
def test_list_empty(self, mock_manager_class: MagicMock) -> None:
|
def test_list_empty(self, mock_manager_class: MagicMock) -> None:
|
||||||
|
|||||||
@@ -212,17 +212,14 @@ class TestAnsibleExecutorAdHoc:
|
|||||||
result_mock.stderr = ""
|
result_mock.stderr = ""
|
||||||
|
|
||||||
with patch("subprocess.run", return_value=result_mock) as mock_run:
|
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"
|
assert result == "ok"
|
||||||
cmd = mock_run.call_args.args[0]
|
cmd = mock_run.call_args.args[0]
|
||||||
assert "--become" in cmd
|
assert "--become" in cmd
|
||||||
assert "--ask-become-pass" 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)
|
executor = AnsibleExecutor(log_dir=tmp_path)
|
||||||
result_mock = MagicMock()
|
result_mock = MagicMock()
|
||||||
result_mock.stdout = "ok\n"
|
result_mock.stdout = "ok\n"
|
||||||
@@ -230,14 +227,37 @@ class TestAnsibleExecutorAdHoc:
|
|||||||
result_mock.stderr = ""
|
result_mock.stderr = ""
|
||||||
|
|
||||||
with patch("subprocess.run", return_value=result_mock) as mock_run:
|
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(
|
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"
|
assert result == "ok"
|
||||||
cmd = mock_run.call_args.args[0]
|
cmd = mock_run.call_args.args[0]
|
||||||
assert "--become" in cmd
|
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
|
assert "--ask-become-pass" not in cmd
|
||||||
|
|
||||||
def test_run_ad_hoc_check_false(self, tmp_path: Path) -> None:
|
def test_run_ad_hoc_check_false(self, tmp_path: Path) -> None:
|
||||||
|
|||||||
@@ -297,6 +297,20 @@ class TestRunnerManager:
|
|||||||
assert manager._captured_extra_vars["runner_name"] == "r1"
|
assert manager._captured_extra_vars["runner_name"] == "r1"
|
||||||
assert "Stopping Gitea Runner r1 on host" in mock_executor.run.call_args.kwargs["description"]
|
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:
|
def test_enable(self) -> None:
|
||||||
mock_registry = MagicMock()
|
mock_registry = MagicMock()
|
||||||
mock_registry.get.return_value = {"host": "host", "user": "user", "key": None}
|
mock_registry.get.return_value = {"host": "host", "user": "user", "key": None}
|
||||||
@@ -471,10 +485,13 @@ class TestRunnerManager:
|
|||||||
"ubuntu",
|
"ubuntu",
|
||||||
"/key",
|
"/key",
|
||||||
"shell",
|
"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,
|
become=True,
|
||||||
ask_become_pass=True,
|
ask_become_pass=True,
|
||||||
check=False,
|
check=False,
|
||||||
|
become_pass=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_list_runners_exception(self) -> None:
|
def test_list_runners_exception(self) -> None:
|
||||||
|
|||||||
Reference in New Issue
Block a user