diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 41bcae9..78efd8f 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -2,42 +2,56 @@ name: CI on: pull_request: - branches: [master] push: branches: [master] + workflow_dispatch: jobs: - lint: + quality: runs-on: docker steps: - uses: actions/checkout@v4 - - name: Install dependencies - run: | - python3 -m venv .venv - .venv/bin/pip install -e ".[dev]" - .venv/bin/ansible-galaxy collection install -r ansible/requirements.yml + - name: Set up environment + run: make setup - name: Lint all - run: make lint-all - - unit-tests: - runs-on: docker - steps: - - uses: actions/checkout@v4 - - name: Install dependencies run: | - python3 -m venv .venv - .venv/bin/pip install -e ".[dev]" + . .venv/bin/activate + make lint-all - name: Unit tests with 100% coverage - run: make pytest-cov + run: | + . .venv/bin/activate + make pytest-cov + - name: Check unit test speed + run: | + . .venv/bin/activate + python3 scripts/check_test_speed.py --max-seconds 10 molecule-tests: + needs: quality runs-on: docker + strategy: + matrix: + runner-index: [0, 1, 2] steps: - uses: actions/checkout@v4 - - name: Install dependencies + - name: Set up environment + run: make setup + - name: Discover assigned scenarios run: | - python3 -m venv .venv - .venv/bin/pip install -e ".[dev]" - .venv/bin/ansible-galaxy collection install -r ansible/requirements.yml - - name: Molecule tests (all 7 scenarios) - run: make molecule + . .venv/bin/activate + SCENARIOS=$(python3 scripts/distribute_molecule.py --runner-index ${{ matrix.runner-index }} --max-runners 3) + echo "Assigned scenarios: $SCENARIOS" + echo "SCENARIOS=$SCENARIOS" >> $GITHUB_ENV + - name: Run molecule tests + run: | + . .venv/bin/activate + export DOCKER_HOST="unix:///run/user/$(id -u)/docker.sock" + export ANSIBLE_INJECT_INVOCATION=1 + cd ansible/roles/gitea-runner + for s in $SCENARIOS; do + if [ "$s" = "default" ]; then + ANSIBLE_ALLOW_BROKEN_CONDITIONALS=true molecule test + else + ANSIBLE_ALLOW_BROKEN_CONDITIONALS=true molecule test -s "$s" + fi + done diff --git a/Makefile b/Makefile index 5160f23..072d80c 100644 --- a/Makefile +++ b/Makefile @@ -32,7 +32,7 @@ install-hooks: @echo "Git hooks installed." checkmake: - @which checkmake >/dev/null 2>&1 || (which go >/dev/null 2>&1 && go install github.com/mrtazz/checkmake/cmd/checkmake@latest) || (echo "Warning: checkmake not installed. Install Go and run: go install github.com/mrtazz/checkmake/cmd/checkmake@latest" && exit 0) + @python3 scripts/install_checkmake.py install: @if [ -z "$(HOST)" ]; then echo "HOST is required. Example: make install HOST=192.168.1.10"; exit 1; fi diff --git a/ansible/remove-runner.yml b/ansible/remove-runner.yml index 36fa645..37c0846 100644 --- a/ansible/remove-runner.yml +++ b/ansible/remove-runner.yml @@ -11,18 +11,30 @@ - name: Stop gitea-runner systemd instance ansible.builtin.systemd: - name: "gitea-runner@{{ runner_name }}" + name: "gitea-runner-{{ runner_mode | default('docker') }}@{{ runner_name }}" state: stopped daemon_reload: true when: systemd_available.stat.exists - name: Disable gitea-runner systemd instance ansible.builtin.systemd: - name: "gitea-runner@{{ runner_name }}" + name: "gitea-runner-{{ runner_mode | default('docker') }}@{{ runner_name }}" enabled: false daemon_reload: true when: systemd_available.stat.exists + - name: Stop Docker container directly + ansible.builtin.command: "docker stop -t 30 gitea-runner-{{ runner_name }}" + changed_when: true + failed_when: false + when: runner_mode | default('docker') == 'docker' + + - name: Remove Docker container directly + ansible.builtin.command: "docker rm -f gitea-runner-{{ runner_name }}" + changed_when: true + failed_when: false + when: runner_mode | default('docker') == 'docker' + - name: Include deregistration ansible.builtin.include_role: name: gitea-runner @@ -31,7 +43,7 @@ - name: Remove systemd unit file (last instance) ansible.builtin.file: - path: "/etc/systemd/system/gitea-runner@.service" + path: "/etc/systemd/system/gitea-runner-{{ runner_mode | default('docker') }}@.service" state: absent when: remove_systemd_template | default(false) diff --git a/ansible/roles/gitea-runner/molecule/binary/molecule.yml b/ansible/roles/gitea-runner/molecule/binary/molecule.yml index 56c6310..755e682 100644 --- a/ansible/roles/gitea-runner/molecule/binary/molecule.yml +++ b/ansible/roles/gitea-runner/molecule/binary/molecule.yml @@ -4,13 +4,13 @@ driver: platforms: - name: ${MOLECULE_PLATFORM_NAME:-ubuntu-2204} - image: ${MOLECULE_PLATFORM_IMAGE:-geerlingguy/docker-ubuntu2204-ansible:latest} + image: ${MOLECULE_PLATFORM_IMAGE:-ubuntu:22.04} command: ${MOLECULE_PLATFORM_COMMAND:-sleep infinity} volumes: - /sys/fs/cgroup:/sys/fs/cgroup:rw cgroupns_mode: host privileged: true - pre_build_image: true + pre_build_image: false provisioner: name: ansible diff --git a/ansible/roles/gitea-runner/molecule/common/prepare.yml b/ansible/roles/gitea-runner/molecule/common/prepare.yml index 932443b..1cbe51a 100644 --- a/ansible/roles/gitea-runner/molecule/common/prepare.yml +++ b/ansible/roles/gitea-runner/molecule/common/prepare.yml @@ -6,6 +6,7 @@ - name: Update apt cache ansible.builtin.apt: update_cache: true + cache_valid_time: 0 when: ansible_facts['os_family'] == 'Debian' - name: Install python3-debian for deb822_repository module diff --git a/ansible/roles/gitea-runner/molecule/default/molecule.yml b/ansible/roles/gitea-runner/molecule/default/molecule.yml index 56c6310..755e682 100644 --- a/ansible/roles/gitea-runner/molecule/default/molecule.yml +++ b/ansible/roles/gitea-runner/molecule/default/molecule.yml @@ -4,13 +4,13 @@ driver: platforms: - name: ${MOLECULE_PLATFORM_NAME:-ubuntu-2204} - image: ${MOLECULE_PLATFORM_IMAGE:-geerlingguy/docker-ubuntu2204-ansible:latest} + image: ${MOLECULE_PLATFORM_IMAGE:-ubuntu:22.04} command: ${MOLECULE_PLATFORM_COMMAND:-sleep infinity} volumes: - /sys/fs/cgroup:/sys/fs/cgroup:rw cgroupns_mode: host privileged: true - pre_build_image: true + pre_build_image: false provisioner: name: ansible diff --git a/ansible/roles/gitea-runner/molecule/deregister/molecule.yml b/ansible/roles/gitea-runner/molecule/deregister/molecule.yml index 6a9a213..a2d5822 100644 --- a/ansible/roles/gitea-runner/molecule/deregister/molecule.yml +++ b/ansible/roles/gitea-runner/molecule/deregister/molecule.yml @@ -4,13 +4,13 @@ driver: platforms: - name: ${MOLECULE_PLATFORM_NAME:-ubuntu-2204} - image: ${MOLECULE_PLATFORM_IMAGE:-geerlingguy/docker-ubuntu2204-ansible:latest} + image: ${MOLECULE_PLATFORM_IMAGE:-ubuntu:22.04} command: ${MOLECULE_PLATFORM_COMMAND:-sleep infinity} volumes: - /sys/fs/cgroup:/sys/fs/cgroup:rw cgroupns_mode: host privileged: true - pre_build_image: true + pre_build_image: false provisioner: name: ansible diff --git a/ansible/roles/gitea-runner/molecule/lifecycle/molecule.yml b/ansible/roles/gitea-runner/molecule/lifecycle/molecule.yml index 6a9a213..a2d5822 100644 --- a/ansible/roles/gitea-runner/molecule/lifecycle/molecule.yml +++ b/ansible/roles/gitea-runner/molecule/lifecycle/molecule.yml @@ -4,13 +4,13 @@ driver: platforms: - name: ${MOLECULE_PLATFORM_NAME:-ubuntu-2204} - image: ${MOLECULE_PLATFORM_IMAGE:-geerlingguy/docker-ubuntu2204-ansible:latest} + image: ${MOLECULE_PLATFORM_IMAGE:-ubuntu:22.04} command: ${MOLECULE_PLATFORM_COMMAND:-sleep infinity} volumes: - /sys/fs/cgroup:/sys/fs/cgroup:rw cgroupns_mode: host privileged: true - pre_build_image: true + pre_build_image: false provisioner: name: ansible diff --git a/ansible/roles/gitea-runner/molecule/multi-instance/molecule.yml b/ansible/roles/gitea-runner/molecule/multi-instance/molecule.yml index 56c6310..755e682 100644 --- a/ansible/roles/gitea-runner/molecule/multi-instance/molecule.yml +++ b/ansible/roles/gitea-runner/molecule/multi-instance/molecule.yml @@ -4,13 +4,13 @@ driver: platforms: - name: ${MOLECULE_PLATFORM_NAME:-ubuntu-2204} - image: ${MOLECULE_PLATFORM_IMAGE:-geerlingguy/docker-ubuntu2204-ansible:latest} + image: ${MOLECULE_PLATFORM_IMAGE:-ubuntu:22.04} command: ${MOLECULE_PLATFORM_COMMAND:-sleep infinity} volumes: - /sys/fs/cgroup:/sys/fs/cgroup:rw cgroupns_mode: host privileged: true - pre_build_image: true + pre_build_image: false provisioner: name: ansible diff --git a/ansible/roles/gitea-runner/molecule/template-content/molecule.yml b/ansible/roles/gitea-runner/molecule/template-content/molecule.yml index 56c6310..755e682 100644 --- a/ansible/roles/gitea-runner/molecule/template-content/molecule.yml +++ b/ansible/roles/gitea-runner/molecule/template-content/molecule.yml @@ -4,13 +4,13 @@ driver: platforms: - name: ${MOLECULE_PLATFORM_NAME:-ubuntu-2204} - image: ${MOLECULE_PLATFORM_IMAGE:-geerlingguy/docker-ubuntu2204-ansible:latest} + image: ${MOLECULE_PLATFORM_IMAGE:-ubuntu:22.04} command: ${MOLECULE_PLATFORM_COMMAND:-sleep infinity} volumes: - /sys/fs/cgroup:/sys/fs/cgroup:rw cgroupns_mode: host privileged: true - pre_build_image: true + pre_build_image: false provisioner: name: ansible diff --git a/ansible/roles/gitea-runner/molecule/update/molecule.yml b/ansible/roles/gitea-runner/molecule/update/molecule.yml index 6a9a213..a2d5822 100644 --- a/ansible/roles/gitea-runner/molecule/update/molecule.yml +++ b/ansible/roles/gitea-runner/molecule/update/molecule.yml @@ -4,13 +4,13 @@ driver: platforms: - name: ${MOLECULE_PLATFORM_NAME:-ubuntu-2204} - image: ${MOLECULE_PLATFORM_IMAGE:-geerlingguy/docker-ubuntu2204-ansible:latest} + image: ${MOLECULE_PLATFORM_IMAGE:-ubuntu:22.04} command: ${MOLECULE_PLATFORM_COMMAND:-sleep infinity} volumes: - /sys/fs/cgroup:/sys/fs/cgroup:rw cgroupns_mode: host privileged: true - pre_build_image: true + pre_build_image: false provisioner: name: ansible diff --git a/ansible/roles/gitea-runner/tasks/docker.yml b/ansible/roles/gitea-runner/tasks/docker.yml index a839c5e..b586645 100644 --- a/ansible/roles/gitea-runner/tasks/docker.yml +++ b/ansible/roles/gitea-runner/tasks/docker.yml @@ -27,6 +27,7 @@ - gnupg state: present update_cache: true + cache_valid_time: 0 - name: Add Docker GPG key ansible.builtin.get_url: @@ -53,6 +54,7 @@ - docker-compose-plugin state: present update_cache: true + cache_valid_time: 0 - name: Install Docker (Arch Linux) when: diff --git a/ansible/roles/gitea-runner/tasks/docker_mode.yml b/ansible/roles/gitea-runner/tasks/docker_mode.yml index b636aa1..32fee6a 100644 --- a/ansible/roles/gitea-runner/tasks/docker_mode.yml +++ b/ansible/roles/gitea-runner/tasks/docker_mode.yml @@ -44,5 +44,12 @@ changed_when: "'already exists' not in register_output.stdout | default('')" timeout: 60 +- name: Restart gitea-runner service after registration + ansible.builtin.systemd: + name: "gitea-runner-docker@{{ runner_name }}" + state: restarted + daemon_reload: true + when: systemd_available.stat.exists | default(false) | bool + - name: Include service setup ansible.builtin.include_tasks: service.yml diff --git a/ansible/roles/gitea-runner/tasks/integration_test.yml b/ansible/roles/gitea-runner/tasks/integration_test.yml index a96e328..f381ba6 100644 --- a/ansible/roles/gitea-runner/tasks/integration_test.yml +++ b/ansible/roles/gitea-runner/tasks/integration_test.yml @@ -20,7 +20,7 @@ - name: Verify runner container running (Docker mode) ansible.builtin.shell: | - docker ps --filter "name=gitea-runner-{{ inventory_hostname }}" --format "{{ '{{.Status}}' }}" + docker ps --filter "name=gitea-runner-{{ runner_name }}" --format "{{ '{{.Status}}' }}" register: docker_check changed_when: false when: runner_mode == 'docker' diff --git a/ansible/roles/gitea-runner/tasks/register.yml b/ansible/roles/gitea-runner/tasks/register.yml index 4eb0290..f05f127 100644 --- a/ansible/roles/gitea-runner/tasks/register.yml +++ b/ansible/roles/gitea-runner/tasks/register.yml @@ -24,3 +24,10 @@ register: register_output changed_when: "'already exists' not in register_output.stdout | default('')" timeout: 60 + +- name: Restart gitea-runner service after registration + ansible.builtin.systemd: + name: "gitea-runner-binary@{{ runner_name }}" + state: restarted + daemon_reload: true + when: systemd_available.stat.exists | default(false) | bool diff --git a/ansible/roles/gitea-runner/templates/gitea-runner-config.yaml.j2 b/ansible/roles/gitea-runner/templates/gitea-runner-config.yaml.j2 index 62a8c96..0de733e 100644 --- a/ansible/roles/gitea-runner/templates/gitea-runner-config.yaml.j2 +++ b/ansible/roles/gitea-runner/templates/gitea-runner-config.yaml.j2 @@ -3,6 +3,8 @@ log: runner: file: "{{ gitea_runner_file }}" + fetch_timeout: 50s + fetch_interval: 2s container: label: "{{ gitea_runner_container_label }}" diff --git a/pyproject.toml b/pyproject.toml index a356e35..8679029 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ version = "0.1.0" description = "Lean CLI to manage Gitea Actions runners" readme = "README.md" license = {text = "GPL-3.0"} -requires-python = ">=3.11" +requires-python = ">=3.12" classifiers = [ "Programming Language :: Python :: 3", "License :: OSI Approved :: GNU General Public License v3 (GPLv3)", @@ -35,7 +35,7 @@ dev = [ "bandit>=1.8.2", "pre-commit>=4.6.0", # Non-Python dev dependency: checkmake (Makefile linter) - # Install via: go install github.com/mrtazz/checkmake/cmd/checkmake@latest + # Install via: go install github.com/checkmake/checkmake/cmd/checkmake@latest ] [tool.setuptools.packages.find] diff --git a/scripts/distribute_molecule.py b/scripts/distribute_molecule.py new file mode 100644 index 0000000..330e885 --- /dev/null +++ b/scripts/distribute_molecule.py @@ -0,0 +1,103 @@ +#!/usr/bin/env python3 +"""Distribute molecule scenarios across N parallel runners. + +Discovers all molecule scenarios under ansible/roles/*/molecule/ and +splits them evenly across the requested number of runners. + +Usage: + python3 scripts/distribute_molecule.py --runner-index 0 --max-runners 3 + # prints: default deregister + python3 scripts/distribute_molecule.py --list + # prints all scenarios, one per line +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +import click + +from gitea_runner_manager.i18n import _ + +DEFAULT_MAX_RUNNERS = 3 +MOLECULE_ROOT = Path("ansible/roles/gitea-runner/molecule") + + +def discover_scenarios(root: Path | None = None) -> list[str]: + """Return sorted list of molecule scenario directory names.""" + if root is None: + root = MOLECULE_ROOT + if not root.is_dir(): + raise click.ClickException( + _("Molecule directory not found: {path}", path=str(root)) + ) + scenarios = [ + d.name + for d in root.iterdir() + if d.is_dir() and not d.name.startswith("_") and d.name != "common" + ] + return sorted(scenarios) + + +def distribute(scenarios: list[str], max_runners: int) -> list[list[str]]: + """Split *scenarios* into *max_runners* balanced groups (round-robin).""" + groups: list[list[str]] = [[] for _ in range(max_runners)] + for i, scenario in enumerate(scenarios): + groups[i % max_runners].append(scenario) + return groups + + +def scenarios_for_runner( + scenarios: list[str], runner_index: int, max_runners: int +) -> list[str]: + """Return the subset of scenarios assigned to *runner_index*.""" + groups = distribute(scenarios, max_runners) + if runner_index < 0 or runner_index >= len(groups): + raise click.ClickException( + _( + "Runner index {index} out of range (0..{max})", + index=runner_index, + max=max_runners - 1, + ) + ) + return groups[runner_index] + + +@click.command() +@click.option( + "--runner-index", + type=int, + default=None, + help="Zero-based runner index. If omitted, prints all groups.", +) +@click.option( + "--max-runners", + type=int, + default=DEFAULT_MAX_RUNNERS, + show_default=True, + help="Total number of parallel runners.", +) +@click.option( + "--list", + "list_all", + is_flag=True, + help="List all discovered scenarios, one per line.", +) +def cli(runner_index: int | None, max_runners: int, list_all: bool) -> None: + scenarios = discover_scenarios() + if list_all: + for s in scenarios: + click.echo(s) + return + if runner_index is None: + groups = distribute(scenarios, max_runners) + for i, group in enumerate(groups): + click.echo(f"Runner {i}: {' '.join(group) if group else '(none)'}") + return + assigned = scenarios_for_runner(scenarios, runner_index, max_runners) + click.echo(" ".join(assigned)) + + +if __name__ == "__main__": # pragma: no cover + cli() # pragma: no cover diff --git a/scripts/install_checkmake.py b/scripts/install_checkmake.py new file mode 100644 index 0000000..b1d6080 --- /dev/null +++ b/scripts/install_checkmake.py @@ -0,0 +1,73 @@ +#!/usr/bin/env python3 +"""Install checkmake if it is not already present. + +Tries to install via Go if available, otherwise downloads the latest +pre-built Linux binary from the official GitHub releases. +""" + +from __future__ import annotations + +import platform +import shutil +import subprocess # nosec B404 +import sys +from pathlib import Path + +import urllib.request + +import click + +CHECKMAKE_VERSION = "0.3.2" +RELEASE_URL_TEMPLATE = ( + "https://github.com/checkmake/checkmake/releases/download/" + f"v{CHECKMAKE_VERSION}/checkmake-v{CHECKMAKE_VERSION}.linux.{{arch}}" +) +TARGET_PATH = Path("/usr/local/bin/checkmake") + + +def _arch() -> str: + """Return the architecture string used by checkmake releases.""" + machine = platform.machine().lower() + if machine in {"x86_64", "amd64"}: + return "amd64" + if machine in {"aarch64", "arm64"}: + return "arm64" + raise click.ClickException( + f"Unsupported architecture: {machine}" + ) + + +def _install_with_go() -> bool: + """Install checkmake using go install if Go is available.""" + go_bin = shutil.which("go") + if go_bin is None: + return False + subprocess.run( # nosec B603 + [ + go_bin, + "install", + "github.com/checkmake/checkmake/cmd/checkmake@latest", + ], + check=True, + ) + return True + + +def _download_binary() -> None: + """Download the prebuilt checkmake binary for the current architecture.""" + url = RELEASE_URL_TEMPLATE.format(arch=_arch()) + urllib.request.urlretrieve(url, TARGET_PATH) # nosec B310 + TARGET_PATH.chmod(0o755) + + +def main() -> None: + """Install checkmake if not already present.""" + if shutil.which("checkmake") is not None: + return + + if not _install_with_go(): + _download_binary() + + +if __name__ == "__main__": # pragma: no cover + main() # pragma: no cover diff --git a/src/gitea_runner_manager/i18n.py b/src/gitea_runner_manager/i18n.py index 301e3b5..afa2ae4 100644 --- a/src/gitea_runner_manager/i18n.py +++ b/src/gitea_runner_manager/i18n.py @@ -754,6 +754,20 @@ TRANSLATIONS: dict[str, dict[str, str]] = { "ru": "код выхода {code}", "zh": "退出代码 {code}", }, + "Molecule directory not found: {path}": { + "en": "Molecule directory not found: {path}", + "bg": "Директорията на molecule не е намерена: {path}", + "de": "Molecule-Verzeichnis nicht gefunden: {path}", + "ru": "Директория molecule не найдена: {path}", + "zh": "未找到 molecule 目录: {path}", + }, + "Runner index {index} out of range (0..{max})": { + "en": "Runner index {index} out of range (0..{max})", + "bg": "Индексът на runner {index} е извън диапазона (0..{max})", + "de": "Runner-Index {index} außerhalb des Bereichs (0..{max})", + "ru": "Индекс runner {index} вне диапазона (0..{max})", + "zh": "Runner 索引 {index} 超出范围 (0..{max})", + }, } diff --git a/tests/unit/test_distribute_molecule.py b/tests/unit/test_distribute_molecule.py new file mode 100644 index 0000000..861e9ff --- /dev/null +++ b/tests/unit/test_distribute_molecule.py @@ -0,0 +1,135 @@ +"""Unit tests for scripts/distribute_molecule.py.""" + +from pathlib import Path +from unittest.mock import patch + +import click +import pytest + +from scripts.distribute_molecule import ( + MOLECULE_ROOT, + discover_scenarios, + distribute, + scenarios_for_runner, +) + + +class TestDiscoverScenarios: + def test_discovers_scenarios(self, tmp_path: Path) -> None: + root = tmp_path / "molecule" + (root / "default").mkdir(parents=True) + (root / "binary").mkdir(parents=True) + (root / "common").mkdir(parents=True) + (root / "_shared").mkdir(parents=True) + result = discover_scenarios(root) + assert result == ["binary", "default"] + + def test_raises_when_dir_missing(self, tmp_path: Path) -> None: + with pytest.raises(click.ClickException) as exc: + discover_scenarios(tmp_path / "nonexistent") + assert "not found" in str(exc.value) + + def test_default_root_constant(self) -> None: + assert Path("ansible/roles/gitea-runner/molecule") == MOLECULE_ROOT + + +class TestDistribute: + def test_even_split(self) -> None: + scenarios = ["a", "b", "c", "d", "e", "f"] + groups = distribute(scenarios, 3) + assert groups == [["a", "d"], ["b", "e"], ["c", "f"]] + + def test_uneven_split(self) -> None: + scenarios = ["a", "b", "c", "d", "e"] + groups = distribute(scenarios, 3) + assert groups == [["a", "d"], ["b", "e"], ["c"]] + + def test_more_runners_than_scenarios(self) -> None: + scenarios = ["a", "b"] + groups = distribute(scenarios, 5) + assert groups == [["a"], ["b"], [], [], []] + + def test_single_runner(self) -> None: + scenarios = ["a", "b", "c"] + groups = distribute(scenarios, 1) + assert groups == [["a", "b", "c"]] + + def test_empty_scenarios(self) -> None: + groups = distribute([], 3) + assert groups == [[], [], []] + + +class TestScenariosForRunner: + def test_returns_correct_subset(self) -> None: + scenarios = ["a", "b", "c", "d", "e", "f"] + assert scenarios_for_runner(scenarios, 0, 3) == ["a", "d"] + assert scenarios_for_runner(scenarios, 1, 3) == ["b", "e"] + assert scenarios_for_runner(scenarios, 2, 3) == ["c", "f"] + + def test_out_of_range_raises(self) -> None: + with pytest.raises(click.ClickException) as exc: + scenarios_for_runner(["a"], 5, 3) + assert "out of range" in str(exc.value) + + def test_negative_index_raises(self) -> None: + with pytest.raises(click.ClickException) as exc: + scenarios_for_runner(["a"], -1, 3) + assert "out of range" in str(exc.value) + + +class TestCli: + def test_list_flag(self, tmp_path: Path) -> None: + from click.testing import CliRunner + + from scripts.distribute_molecule import cli + + root = tmp_path / "molecule" + (root / "alpha").mkdir(parents=True) + (root / "beta").mkdir(parents=True) + with patch("scripts.distribute_molecule.MOLECULE_ROOT", root): + runner = CliRunner() + result = runner.invoke(cli, ["--list"]) + assert result.exit_code == 0 + assert "alpha" in result.output + assert "beta" in result.output + + def test_no_runner_index_prints_all_groups(self, tmp_path: Path) -> None: + from click.testing import CliRunner + + from scripts.distribute_molecule import cli + + root = tmp_path / "molecule" + for s in ["a", "b", "c"]: + (root / s).mkdir(parents=True) + with patch("scripts.distribute_molecule.MOLECULE_ROOT", root): + runner = CliRunner() + result = runner.invoke(cli, ["--max-runners", "3"]) + assert result.exit_code == 0 + assert "Runner 0:" in result.output + assert "Runner 1:" in result.output + assert "Runner 2:" in result.output + + def test_runner_index_prints_assigned(self, tmp_path: Path) -> None: + from click.testing import CliRunner + + from scripts.distribute_molecule import cli + + root = tmp_path / "molecule" + for s in ["a", "b", "c"]: + (root / s).mkdir(parents=True) + with patch("scripts.distribute_molecule.MOLECULE_ROOT", root): + runner = CliRunner() + result = runner.invoke(cli, ["--runner-index", "1", "--max-runners", "3"]) + assert result.exit_code == 0 + assert result.output.strip() == "b" + + +def test_main_module_block() -> None: + import scripts.distribute_molecule as dm + + with open(dm.__file__) as f: + source = f.read() + source = source.replace('if __name__ == "__main__":\n cli()\n', "") + namespace = dict(dm.__dict__) + exec(compile(source, dm.__file__, "exec"), namespace) + assert callable(namespace["cli"]) diff --git a/tests/unit/test_executor.py b/tests/unit/test_executor.py index abd863c..a412124 100644 --- a/tests/unit/test_executor.py +++ b/tests/unit/test_executor.py @@ -111,12 +111,9 @@ class TestAnsibleExecutorExtractStatus: executor = AnsibleExecutor() log_file = tmp_path / "test.log" log_file.write_text("incomplete") - log_file.chmod(0o000) - try: + with patch("gitea_runner_manager.executor.open", side_effect=OSError("read error")): status = executor._extract_status(log_file) - assert status is None - finally: - log_file.chmod(0o644) + assert status is None class TestAnsibleExecutorPrepareLog: diff --git a/tests/unit/test_install_checkmake.py b/tests/unit/test_install_checkmake.py new file mode 100644 index 0000000..9acba5a --- /dev/null +++ b/tests/unit/test_install_checkmake.py @@ -0,0 +1,94 @@ +from __future__ import annotations + +import platform +from pathlib import Path +from unittest.mock import patch + +import pytest +from click import ClickException + +import scripts.install_checkmake as install_checkmake + + +class TestArch: + def test_amd64(self) -> None: + with patch.object(platform, "machine", return_value="x86_64"): + assert install_checkmake._arch() == "amd64" + + def test_arm64(self) -> None: + with patch.object(platform, "machine", return_value="aarch64"): + assert install_checkmake._arch() == "arm64" + + def test_unsupported(self) -> None: + with patch.object(platform, "machine", return_value="riscv64"): + with pytest.raises(ClickException): + install_checkmake._arch() + + +class TestInstallWithGo: + def test_no_go(self) -> None: + with patch("shutil.which", return_value=None): + assert install_checkmake._install_with_go() is False + + def test_with_go(self) -> None: + with patch("shutil.which", return_value="/usr/bin/go"): + with patch("subprocess.run") as mock_run: + assert install_checkmake._install_with_go() is True + mock_run.assert_called_once_with( + [ + "/usr/bin/go", + "install", + "github.com/checkmake/checkmake/cmd/checkmake@latest", + ], + check=True, + ) + + +class TestDownloadBinary: + def test_download(self, tmp_path: Path) -> None: + target = tmp_path / "checkmake" + + def _write_file(url: str, path: str) -> tuple[str, None]: + Path(path).write_bytes(b"binary") + return path, None + + with patch.object(install_checkmake, "TARGET_PATH", target): + with patch.object(platform, "machine", return_value="x86_64"): + with patch("urllib.request.urlretrieve", side_effect=_write_file) as mock_retrieve: + install_checkmake._download_binary() + mock_retrieve.assert_called_once() + assert target.exists() + assert target.stat().st_mode & 0o111 + + +class TestMain: + def test_already_installed(self) -> None: + with patch("shutil.which", return_value="/usr/bin/checkmake"): + install_checkmake.main() + + def test_install_with_go(self) -> None: + with patch("shutil.which", side_effect=[None, "/usr/bin/go"]): + with patch("subprocess.run") as mock_run: + install_checkmake.main() + mock_run.assert_called_once_with( + [ + "/usr/bin/go", + "install", + "github.com/checkmake/checkmake/cmd/checkmake@latest", + ], + check=True, + ) + + def test_download_when_no_go(self, tmp_path: Path) -> None: + target = tmp_path / "checkmake" + + def _write_file(url: str, path: str) -> tuple[str, None]: + Path(path).write_bytes(b"binary") + return path, None + + with patch.object(install_checkmake, "TARGET_PATH", target): + with patch("shutil.which", side_effect=[None, None]): + with patch.object(platform, "machine", return_value="x86_64"): + with patch("urllib.request.urlretrieve", side_effect=_write_file) as mock_retrieve: + install_checkmake.main() + mock_retrieve.assert_called_once()