diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index e1247d6..ea0870a 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -176,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' diff --git a/Makefile b/Makefile index 2f6269d..1720d5e 100644 --- a/Makefile +++ b/Makefile @@ -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, @@ -66,6 +68,9 @@ setup-release: $(VENV)/bin/activate .env configure-gitea-pypi # 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, 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; \ @@ -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 diff --git a/ansible/restart-runner.yml b/ansible/restart-runner.yml new file mode 100644 index 0000000..3d75a73 --- /dev/null +++ b/ansible/restart-runner.yml @@ -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 diff --git a/ansible/roles/gitea-runner/molecule/remove/verify.yml b/ansible/roles/gitea-runner/molecule/remove/verify.yml index 44866ca..9c52bfa 100644 --- a/ansible/roles/gitea-runner/molecule/remove/verify.yml +++ b/ansible/roles/gitea-runner/molecule/remove/verify.yml @@ -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" diff --git a/ansible/roles/gitea-runner/tasks/resolve_uid.yml b/ansible/roles/gitea-runner/tasks/resolve_uid.yml new file mode 100644 index 0000000..31c5261 --- /dev/null +++ b/ansible/roles/gitea-runner/tasks/resolve_uid.yml @@ -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] }}" diff --git a/ansible/roles/gitea-runner/tasks/user_setup.yml b/ansible/roles/gitea-runner/tasks/user_setup.yml index 6159853..16deb97 100644 --- a/ansible/roles/gitea-runner/tasks/user_setup.yml +++ b/ansible/roles/gitea-runner/tasks/user_setup.yml @@ -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: diff --git a/ansible/start-runner.yml b/ansible/start-runner.yml index 3931025..bf5cbee 100644 --- a/ansible/start-runner.yml +++ b/ansible/start-runner.yml @@ -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 diff --git a/ansible/status-runner.yml b/ansible/status-runner.yml index 5580f25..a543b34 100644 --- a/ansible/status-runner.yml +++ b/ansible/status-runner.yml @@ -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 diff --git a/ansible/stop-runner.yml b/ansible/stop-runner.yml index b136575..24db13f 100644 --- a/ansible/stop-runner.yml +++ b/ansible/stop-runner.yml @@ -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 diff --git a/pyproject.toml b/pyproject.toml index eeec6f8..ecf791b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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.23.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.23.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)", ] diff --git a/scripts/__init__.py b/scripts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/scripts/prune_runner_images.py b/scripts/prune_runner_images.py new file mode 100644 index 0000000..41b3a12 --- /dev/null +++ b/scripts/prune_runner_images.py @@ -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 ```` 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 "" 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()) diff --git a/scripts/tests/__init__.py b/scripts/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/scripts/tests/test_prune_runner_images.py b/scripts/tests/test_prune_runner_images.py new file mode 100644 index 0000000..bdc6692 --- /dev/null +++ b/scripts/tests/test_prune_runner_images.py @@ -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:\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, + ) diff --git a/src/gitea_runner_manager/cli.py b/src/gitea_runner_manager/cli.py index 6da8a68..2683a3e 100644 --- a/src/gitea_runner_manager/cli.py +++ b/src/gitea_runner_manager/cli.py @@ -4,6 +4,7 @@ from __future__ import annotations import functools import os +import sys from collections.abc import Callable 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.")) @click.argument("runner_name") @_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.")) +@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}") -def list_runners() -> None: +def list_runners(ask_become_pass: bool) -> None: + become_pass = _collect_become_pass(ask_become_pass) + manager = RunnerManager() - runners = manager.list_runners() + runners = manager.list_runners(become_pass=become_pass) if not runners: click.echo(_("No runners registered. Use 'grm install' to add one.")) diff --git a/src/gitea_runner_manager/executor.py b/src/gitea_runner_manager/executor.py index 02a8fc8..bcf8714 100644 --- a/src/gitea_runner_manager/executor.py +++ b/src/gitea_runner_manager/executor.py @@ -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,31 @@ 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-") + with os.fdopen(fd, "w") as f: + f.write(become_pass) + os.chmod(password_file, 0o600) + cmd.extend(["--become-password-file", password_file]) + else: + cmd.append("--ask-become-pass") env = 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( diff --git a/src/gitea_runner_manager/runner_manager.py b/src/gitea_runner_manager/runner_manager.py index b0e1763..a62d861 100644 --- a/src/gitea_runner_manager/runner_manager.py +++ b/src/gitea_runner_manager/runner_manager.py @@ -226,6 +226,29 @@ class RunnerManager: ) 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( self, name: str, @@ -345,8 +368,14 @@ 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) -> 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. + """ runners = self._registry.list() result: list[dict[str, str]] = [] for name, info in runners.items(): @@ -369,10 +398,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, check=False, + become_pass=become_pass, ) service_status = self._parse_status(stdout) except AnsibleError: diff --git a/src/gitea_runner_manager/translations.json b/src/gitea_runner_manager/translations.json index e5c0c70..83bd443 100644 --- a/src/gitea_runner_manager/translations.json +++ b/src/gitea_runner_manager/translations.json @@ -295,6 +295,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 +343,30 @@ "ru": "Удаление Gitea Runner {name} с {host}", "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.": { "bg": "Runner '{name}' не е намерен в регистъра.", "de": "Runner '{name}' nicht in der Registrierung gefunden.", @@ -463,6 +495,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", diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py index 5a09500..59f80a0 100644 --- a/tests/unit/test_cli.py +++ b/tests/unit/test_cli.py @@ -286,6 +286,22 @@ class TestCLI: 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") def test_enable(self, mock_manager_class: MagicMock) -> None: mock_manager = MagicMock() @@ -539,7 +555,33 @@ 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) + + @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") def test_list_empty(self, mock_manager_class: MagicMock) -> None: diff --git a/tests/unit/test_executor.py b/tests/unit/test_executor.py index a412124..9f7173e 100644 --- a/tests/unit/test_executor.py +++ b/tests/unit/test_executor.py @@ -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,37 @@ 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_check_false(self, tmp_path: Path) -> None: diff --git a/tests/unit/test_runner_manager.py b/tests/unit/test_runner_manager.py index 36537be..f194e54 100644 --- a/tests/unit/test_runner_manager.py +++ b/tests/unit/test_runner_manager.py @@ -297,6 +297,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 +485,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, check=False, + become_pass=None, ) def test_list_runners_exception(self) -> None: