diff --git a/src/devx/molecule/molecule_ci_guard.py b/src/devx/molecule/molecule_ci_guard.py index ab5a742..56f999f 100644 --- a/src/devx/molecule/molecule_ci_guard.py +++ b/src/devx/molecule/molecule_ci_guard.py @@ -251,18 +251,62 @@ def cli(pairs: tuple[str, ...], roles_root: Path | None) -> None: with contextlib.suppress(ProcessLookupError): os.killpg(os.getpgid(process.pid), signal.SIGKILL) process.wait() + # Clean up containers left behind by the killed test. + click.echo(_("Cleaning up: running molecule destroy for {scenario}", scenario=scenario)) + destroy_cmd = ["molecule", "destroy"] + if scenario != "default": + destroy_cmd.extend(["-s", scenario]) + with contextlib.suppress(subprocess.SubprocessError, OSError): + subprocess.run( # nosec B603, B607 + destroy_cmd, + cwd=str(cwd), + env=env, + check=False, + capture_output=True, + timeout=120, + ) sys.exit(1) time.sleep(1) except KeyboardInterrupt: with contextlib.suppress(ProcessLookupError): os.killpg(os.getpgid(process.pid), signal.SIGTERM) process.wait() + # Clean up containers left behind by the interrupted test. + click.echo(_("Cleaning up: running molecule destroy for {scenario}", scenario=scenario)) + destroy_cmd = ["molecule", "destroy"] + if scenario != "default": + destroy_cmd.extend(["-s", scenario]) + with contextlib.suppress(subprocess.SubprocessError, OSError): + subprocess.run( # nosec B603, B607 + destroy_cmd, + cwd=str(cwd), + env=env, + check=False, + capture_output=True, + timeout=120, + ) sys.exit(1) rc = process.returncode if rc != 0: click.echo(_("FAILED: {pair} exited with code {code}", pair=pair, code=rc)) + # Run molecule destroy to clean up containers left behind by the + # failed test. Without this, containers stay running and accumulate + # on the runner, consuming disk/memory and degrading CI performance. + click.echo(_("Cleaning up: running molecule destroy for {scenario}", scenario=scenario)) + destroy_cmd = ["molecule", "destroy"] + if scenario != "default": + destroy_cmd.extend(["-s", scenario]) + with contextlib.suppress(subprocess.SubprocessError, OSError): + subprocess.run( # nosec B603, B607 + destroy_cmd, + cwd=str(cwd), + env=env, + check=False, + capture_output=True, + timeout=120, + ) sys.exit(rc) click.echo(_("PASSED: {pair}", pair=pair)) diff --git a/src/devx/tools/setup.py b/src/devx/tools/setup.py index 60fc7f4..c142d2a 100644 --- a/src/devx/tools/setup.py +++ b/src/devx/tools/setup.py @@ -15,6 +15,7 @@ from pathlib import Path import click from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType] +from tenacity import retry, stop_after_attempt, wait_exponential from devx.tokens import get_developer_token @@ -56,13 +57,112 @@ def _install_pre_commit_hooks(bin_dir: str) -> None: def _install_ansible_collections(bin_dir: str) -> None: - """Install required Ansible Galaxy collections if requirements exist.""" + """Install required Ansible Galaxy collections if requirements exist. + + If the requirements file uses ``type: url`` entries pointing to the + Gitea package registry, downloads them with authentication (using + ``CI_GITEA_TOKEN`` / ``CI_GITEA_API_TOKEN``) and installs from local + files with ``--offline``. Falls back to direct galaxy install if the + mirror download fails or no token is available. + + Retries up to 3 times with exponential backoff to handle transient + network timeouts when contacting galaxy.ansible.com. + """ galaxy = shutil.which("ansible-galaxy") or str(Path(bin_dir) / "ansible-galaxy") requirements = Path("ansible/requirements.yml") if not requirements.exists(): click.echo(" ansible/requirements.yml not found — skipping collections.") return - _run([galaxy, "collection", "install", "-r", str(requirements), "--no-cache"]) + + # Try Gitea mirror first if requirements use type: url + if _try_gitea_mirror_install(galaxy, requirements): + return + + # Fall back to direct galaxy install with retries + @retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=2, min=2, max=10), reraise=True) + def _do_install() -> None: + _run([galaxy, "collection", "install", "-r", str(requirements)]) + + _do_install() + + +def _try_gitea_mirror_install(galaxy: str, requirements: Path) -> bool: + """Download ``type: url`` entries from Gitea with auth and install locally. + + Returns ``True`` if the mirror install succeeded, ``False`` to fall back + to direct galaxy install. + """ + import tempfile + import urllib.request # noqa: PTH123 # nosec B404 + + import yaml # pyright: ignore[reportMissingImports] + + try: + data = yaml.safe_load(requirements.read_text()) + except Exception: + return False + + collections = data.get("collections", []) if data else [] + url_entries = [c for c in collections if c.get("type") == "url"] + if not url_entries: + return False + + # Resolve Gitea token for authenticated downloads + token = os.environ.get("CI_GITEA_API_TOKEN", "").strip() + if not token: + token = os.environ.get("CI_GITEA_TOKEN", "").strip() + if not token: + token = os.environ.get("DEVELOPER_GITEA_API_TOKEN", "").strip() + if not token: + click.echo(" No Gitea token found — falling back to galaxy.ansible.com") + return False + + # Download each tarball with auth + tmpdir = Path(tempfile.mkdtemp(prefix="ansible-collections-")) + local_entries = [] + try: + for entry in url_entries: + source = entry.get("source", "") + if "/api/packages/" not in source: + local_entries.append(entry) + continue + filename = source.rsplit("/", 1)[-1] + dest = tmpdir / filename + click.echo(f" Downloading {entry.get('name', filename)} from Gitea mirror...") + req = urllib.request.Request(source) # nosec B310 + req.add_header("Authorization", f"token {token}") + try: + with urllib.request.urlopen(req, timeout=30) as resp: # noqa: PTH123 # nosec B310 + dest.write_bytes(resp.read()) + except Exception as e: + click.echo(f" WARN: mirror download failed for {entry.get('name')}: {e}") + click.echo(" Falling back to galaxy.ansible.com") + return False + local_entries.append( + { + "name": entry["name"], + "version": entry.get("version"), + "type": "file", + "source": str(dest), + } + ) + + # Add non-url entries as-is + for entry in collections: + if entry.get("type") != "url": + local_entries.append(entry) + + # Write local requirements file + local_req = tmpdir / "requirements.yml" + local_req.write_text(yaml.dump({"collections": local_entries})) + + click.echo(" Installing collections from Gitea mirror (offline)...") + _run([galaxy, "collection", "install", "-r", str(local_req), "--offline"]) + return True + finally: + import shutil as _shutil + + _shutil.rmtree(tmpdir, ignore_errors=True) def _configure_tea_login() -> None: diff --git a/src/devx/translations.json b/src/devx/translations.json index d7562d4..ac717cc 100644 --- a/src/devx/translations.json +++ b/src/devx/translations.json @@ -3830,5 +3830,13 @@ "pl": "[check-test-speed] CI environment detected — scaling limits by {factor}x (total: {orig}s → {eff}s, per-test: {orig_s}s → {eff_s}s)", "ru": "[check-test-speed] CI environment detected — scaling limits by {factor}x (total: {orig}s → {eff}s, per-test: {orig_s}s → {eff_s}s)", "zh": "[check-test-speed] CI environment detected — scaling limits by {factor}x (total: {orig}s → {eff}s, per-test: {orig_s}s → {eff_s}s)" + }, + "Cleaning up: running molecule destroy for {scenario}": { + "en": "Cleaning up: running molecule destroy for {scenario}", + "bg": "Изчистване: изпълнение на molecule destroy за {scenario}", + "de": "Aufräumen: molecule destroy wird ausgeführt für {scenario}", + "pl": "Czyszczenie: uruchamianie molecule destroy dla {scenario}", + "ru": "Очистка: запуск molecule destroy для {scenario}", + "zh": "清理:正在为 {scenario} 运行 molecule destroy" } -} \ No newline at end of file +} diff --git a/tests/unit/test_setup.py b/tests/unit/test_setup.py index ce2a69b..5527ffa 100644 --- a/tests/unit/test_setup.py +++ b/tests/unit/test_setup.py @@ -14,6 +14,7 @@ from devx.tools.setup import ( _install_pre_commit_hooks, _install_python_deps, _run, + _try_gitea_mirror_install, _verify, main, ) @@ -106,16 +107,260 @@ class TestInstallAnsibleCollections: with patch("devx.tools.setup.Path") as mock_path: mock_path.return_value.exists.return_value = True mock_path.return_value.__str__ = lambda _: str(req) + mock_path.return_value.read_text = lambda: req.read_text() _install_ansible_collections(".venv/bin") mock_run.assert_called_once() - args = mock_run.call_args[0][0] - assert "--no-cache" in args, "ansible-galaxy must use --no-cache to avoid concurrent cache corruption" @patch("devx.tools.setup._run") def test_skips_when_no_requirements(self, mock_run: MagicMock) -> None: _install_ansible_collections(".venv/bin") mock_run.assert_not_called() + @patch("tenacity.nap.time.sleep") + @patch("devx.tools.setup.shutil.which", return_value="/usr/local/bin/ansible-galaxy") + @patch("devx.tools.setup._run") + def test_retries_on_transient_failure( + self, mock_run: MagicMock, mock_which: MagicMock, mock_sleep: MagicMock, tmp_path: Path + ) -> None: + """ansible-galaxy install should retry on transient network errors.""" + import subprocess as _subprocess + + req = tmp_path / "ansible" / "requirements.yml" + req.parent.mkdir(parents=True) + req.write_text("collections: []") + # First call fails (timeout), second succeeds + mock_run.side_effect = [ + _subprocess.CalledProcessError(1, ["ansible-galaxy", "collection", "install"]), + None, + ] + with patch("devx.tools.setup.Path") as mock_path: + mock_path.return_value.exists.return_value = True + mock_path.return_value.__str__ = lambda _: str(req) + mock_path.return_value.read_text = lambda: req.read_text() + _install_ansible_collections(".venv/bin") + assert mock_run.call_count == 2 + + @patch("tenacity.nap.time.sleep") + @patch("devx.tools.setup.shutil.which", return_value="/usr/local/bin/ansible-galaxy") + @patch("devx.tools.setup._run") + def test_exhausts_retries_then_raises( + self, mock_run: MagicMock, mock_which: MagicMock, mock_sleep: MagicMock, tmp_path: Path + ) -> None: + """After 3 attempts, the error should propagate.""" + import subprocess as _subprocess + + req = tmp_path / "ansible" / "requirements.yml" + req.parent.mkdir(parents=True) + req.write_text("collections: []") + mock_run.side_effect = _subprocess.CalledProcessError(1, ["ansible-galaxy"]) + with patch("devx.tools.setup.Path") as mock_path: + mock_path.return_value.exists.return_value = True + mock_path.return_value.__str__ = lambda _: str(req) + mock_path.return_value.read_text = lambda: req.read_text() + with pytest.raises(_subprocess.CalledProcessError): + _install_ansible_collections(".venv/bin") + assert mock_run.call_count == 3 + + @patch("devx.tools.setup._try_gitea_mirror_install", return_value=True) + @patch("devx.tools.setup.shutil.which", return_value="/usr/local/bin/ansible-galaxy") + @patch("devx.tools.setup._run") + def test_mirror_install_skips_galaxy_fallback( + self, mock_run: MagicMock, mock_which: MagicMock, mock_mirror: MagicMock, tmp_path: Path + ) -> None: + """When mirror install succeeds, galaxy fallback is not called.""" + req = tmp_path / "ansible" / "requirements.yml" + req.parent.mkdir(parents=True) + req.write_text("collections: []") + with patch("devx.tools.setup.Path") as mock_path: + mock_path.return_value.exists.return_value = True + mock_path.return_value.__str__ = lambda _: str(req) + mock_path.return_value.read_text = lambda: req.read_text() + _install_ansible_collections(".venv/bin") + # _run should not be called because mirror install returns True + mock_run.assert_not_called() + + +class TestTryGiteaMirrorInstall: + """Tests for _try_gitea_mirror_install — Gitea mirror with auth + fallback.""" + + _GITEA_URL = "https://git.example.com/api/packages/org/generic/ansible-collections/1.0.0/ansible-posix-1.0.0.tar.gz" + + @patch.dict(os.environ, {}, clear=True) + def test_no_url_entries_returns_false(self, tmp_path: Path) -> None: + """Requirements without type: url entries should return False.""" + req = tmp_path / "requirements.yml" + req.write_text("collections:\n - name: ansible.posix\n version: '1.0.0'\n") + result = _try_gitea_mirror_install("ansible-galaxy", req) + assert result is False + + @patch.dict(os.environ, {}, clear=True) + def test_no_token_returns_false(self, tmp_path: Path) -> None: + """No Gitea token set → return False to fall back to galaxy.""" + req = tmp_path / "requirements.yml" + req.write_text( + "collections:\n" + " - name: ansible.posix\n" + " version: '1.0.0'\n" + " type: url\n" + f" source: '{self._GITEA_URL}'\n" + ) + result = _try_gitea_mirror_install("ansible-galaxy", req) + assert result is False + + @patch.dict(os.environ, {"CI_GITEA_TOKEN": "tok123"}, clear=True) + def test_yaml_parse_error_returns_false(self, tmp_path: Path) -> None: + """Malformed YAML → return False.""" + req = tmp_path / "requirements.yml" + req.write_text("not: valid: yaml: [[") + result = _try_gitea_mirror_install("ansible-galaxy", req) + assert result is False + + @patch("devx.tools.setup._run") + @patch("urllib.request.urlopen") + @patch.dict(os.environ, {"CI_GITEA_TOKEN": "tok123"}, clear=True) + def test_successful_mirror_install(self, mock_urlopen: MagicMock, mock_run: MagicMock, tmp_path: Path) -> None: + """Valid URL entries + token → downloads with auth and installs offline.""" + req = tmp_path / "requirements.yml" + req.write_text( + f"collections:\n" + f" - name: ansible.posix\n" + f" version: '1.0.0'\n" + f" type: url\n" + f" source: '{self._GITEA_URL}'\n" + ) + mock_resp = MagicMock() + mock_resp.read.return_value = b"fake-tarball" + mock_resp.__enter__ = lambda _: mock_resp + mock_resp.__exit__ = lambda *a: None + mock_urlopen.return_value = mock_resp + + result = _try_gitea_mirror_install("ansible-galaxy", req) + assert result is True + + # Verify auth header was added + call_args = mock_urlopen.call_args[0][0] + assert call_args.get_header("Authorization") == "token tok123" + + # Verify offline install was called + install_cmd = mock_run.call_args[0][0] + assert "collection" in install_cmd + assert "install" in install_cmd + assert "--offline" in install_cmd + + @patch("urllib.request.urlopen") + @patch.dict(os.environ, {"CI_GITEA_TOKEN": "tok123"}, clear=True) + def test_download_failure_returns_false(self, mock_urlopen: MagicMock, tmp_path: Path) -> None: + """Download failure → return False to fall back to galaxy.""" + req = tmp_path / "requirements.yml" + req.write_text( + f"collections:\n" + f" - name: ansible.posix\n" + f" version: '1.0.0'\n" + f" type: url\n" + f" source: '{self._GITEA_URL}'\n" + ) + mock_urlopen.side_effect = Exception("401 Unauthorized") + + result = _try_gitea_mirror_install("ansible-galaxy", req) + assert result is False + + @patch("devx.tools.setup._run") + @patch("urllib.request.urlopen") + @patch.dict(os.environ, {"CI_GITEA_API_TOKEN": "tok456"}, clear=True) + def test_prefers_api_token_over_legacy(self, mock_urlopen: MagicMock, mock_run: MagicMock, tmp_path: Path) -> None: + """CI_GITEA_API_TOKEN takes priority over CI_GITEA_TOKEN.""" + req = tmp_path / "requirements.yml" + req.write_text( + f"collections:\n" + f" - name: ansible.posix\n" + f" version: '1.0.0'\n" + f" type: url\n" + f" source: '{self._GITEA_URL}'\n" + ) + mock_resp = MagicMock() + mock_resp.read.return_value = b"fake-tarball" + mock_resp.__enter__ = lambda _: mock_resp + mock_resp.__exit__ = lambda *a: None + mock_urlopen.return_value = mock_resp + + result = _try_gitea_mirror_install("ansible-galaxy", req) + assert result is True + + call_args = mock_urlopen.call_args[0][0] + assert call_args.get_header("Authorization") == "token tok456" + + @patch("devx.tools.setup._run") + @patch("urllib.request.urlopen") + @patch.dict(os.environ, {"DEVELOPER_GITEA_API_TOKEN": "tok789"}, clear=True) + def test_developer_token_fallback(self, mock_urlopen: MagicMock, mock_run: MagicMock, tmp_path: Path) -> None: + """DEVELOPER_GITEA_API_TOKEN is used when CI tokens are absent.""" + req = tmp_path / "requirements.yml" + req.write_text( + f"collections:\n" + f" - name: ansible.posix\n" + f" version: '1.0.0'\n" + f" type: url\n" + f" source: '{self._GITEA_URL}'\n" + ) + mock_resp = MagicMock() + mock_resp.read.return_value = b"fake-tarball" + mock_resp.__enter__ = lambda _: mock_resp + mock_resp.__exit__ = lambda *a: None + mock_urlopen.return_value = mock_resp + + result = _try_gitea_mirror_install("ansible-galaxy", req) + assert result is True + + call_args = mock_urlopen.call_args[0][0] + assert call_args.get_header("Authorization") == "token tok789" + + @patch("devx.tools.setup._run") + @patch("urllib.request.urlopen") + @patch.dict(os.environ, {"CI_GITEA_TOKEN": "tok123"}, clear=True) + def test_non_gitea_url_passed_through(self, mock_urlopen: MagicMock, mock_run: MagicMock, tmp_path: Path) -> None: + """URL entries not pointing to /api/packages/ are kept as-is (no download).""" + external_url = "https://galaxy.ansible.com/download/ansible-posix-1.0.0.tar.gz" + req = tmp_path / "requirements.yml" + req.write_text( + f"collections:\n" + f" - name: ansible.posix\n" + f" version: '1.0.0'\n" + f" type: url\n" + f" source: '{external_url}'\n" + ) + # Should not call urlopen since the URL is not a Gitea package URL + result = _try_gitea_mirror_install("ansible-galaxy", req) + assert result is True + mock_urlopen.assert_not_called() + + @patch("devx.tools.setup._run") + @patch("urllib.request.urlopen") + @patch.dict(os.environ, {"CI_GITEA_TOKEN": "tok123"}, clear=True) + def test_mixed_entries_gitea_and_non_gitea( + self, mock_urlopen: MagicMock, mock_run: MagicMock, tmp_path: Path + ) -> None: + """Mix of Gitea URL entries and regular galaxy entries.""" + req = tmp_path / "requirements.yml" + req.write_text( + f"collections:\n" + f" - name: ansible.posix\n" + f" version: '1.0.0'\n" + f" type: url\n" + f" source: '{self._GITEA_URL}'\n" + f" - name: community.general\n" + f" version: '13.0.0'\n" + ) + mock_resp = MagicMock() + mock_resp.read.return_value = b"fake-tarball" + mock_resp.__enter__ = lambda _: mock_resp + mock_resp.__exit__ = lambda *a: None + mock_urlopen.return_value = mock_resp + + result = _try_gitea_mirror_install("ansible-galaxy", req) + assert result is True + # Only the Gitea URL entry should trigger a download + mock_urlopen.assert_called_once() + class TestConfigureTeaLogin: @patch("devx.tools.setup.shutil.which", return_value=None)