diff --git a/src/devx/tools/install_tools.py b/src/devx/tools/install_tools.py index 8ff9da6..558bbd5 100644 --- a/src/devx/tools/install_tools.py +++ b/src/devx/tools/install_tools.py @@ -8,6 +8,7 @@ Handles installation of: - tea (Gitea CLI — official command-line tool for Gitea API operations) - hadolint (Dockerfile linter) - vale (prose linter for documentation quality) +- promtool (Prometheus rule validator) Each tool is installed to ``~/.local/bin`` if not already on PATH. Idempotent: skips tools that are already available. @@ -47,6 +48,8 @@ TOFU_VERSION = "1.12.3" VALE_VERSION = "3.15.1" +PROMTOOL_VERSION = "3.5.5" + def _arch() -> str: """Return the architecture string used by release assets (delegates to shared utility).""" @@ -212,7 +215,26 @@ def install_vale() -> bool: return True -TOOL_NAMES = ["actionlint", "git-cliff", "act_runner", "tea", "hadolint", "tofu", "vale"] +def install_promtool() -> bool: + """Install promtool (Prometheus rule validator) if not already present. + + Downloads the official Prometheus release tarball from GitHub and + extracts the ``promtool`` binary to ``~/.local/bin``. + """ + if _is_installed("promtool"): + click.echo("promtool: already installed") + return True + arch = _arch() + url = ( + f"https://github.com/prometheus/prometheus/releases/download/" + f"v{PROMTOOL_VERSION}/prometheus-{PROMTOOL_VERSION}.linux-{arch}.tar.gz" + ) + dest = _download_and_extract_tarball(url, "promtool") + click.echo(f"promtool: installed to {dest}") + return True + + +TOOL_NAMES = ["actionlint", "git-cliff", "act_runner", "tea", "hadolint", "tofu", "vale", "promtool"] def _install_tool(name: str) -> bool: @@ -231,6 +253,8 @@ def _install_tool(name: str) -> bool: return install_tofu() if name == "vale": return install_vale() + if name == "promtool": + return install_promtool() raise click.ClickException(f"Unknown tool: {name}") diff --git a/tests/unit/test_install_tools.py b/tests/unit/test_install_tools.py index f9e5f8f..2bbce84 100644 --- a/tests/unit/test_install_tools.py +++ b/tests/unit/test_install_tools.py @@ -297,6 +297,47 @@ class TestInstallVale: assert (tmp_path / "vale").exists() +class TestInstallPromtool: + def test_already_installed(self) -> None: + with patch.object(install_tools, "_is_installed", return_value=True): + assert install_tools.install_promtool() is True + + def test_install(self, tmp_path: Path) -> None: + import io + import tarfile + + tarball_path = tmp_path / "archive.tar.gz" + binary_content = b"fake promtool" + with tarfile.open(tarball_path, "w:gz") as tar: + info = tarfile.TarInfo(name="promtool") + info.size = len(binary_content) + tar.addfile(info, io.BytesIO(binary_content)) + + with patch.object(install_tools, "_is_installed", return_value=False): + with patch.object(install_tools, "TARGET_DIR", tmp_path): + with patch.object(install_tools, "_arch", return_value="amd64"): + with patch.object( + install_tools, + "_download", + side_effect=lambda url, dest: Path(dest).write_bytes(tarball_path.read_bytes()), + ): + assert install_tools.install_promtool() is True + assert (tmp_path / "promtool").exists() + + def test_url_contains_version(self, tmp_path: Path) -> None: + """Verify the download URL includes the correct promtool version.""" + captured_url = [] + + def fake_extract(url: str, binary_name: str) -> Path: + captured_url.append(url) + return tmp_path / binary_name + + with patch.object(install_tools, "_is_installed", return_value=False): + with patch.object(install_tools, "_download_and_extract_tarball", side_effect=fake_extract): + install_tools.install_promtool() + assert any(f"v{install_tools.PROMTOOL_VERSION}" in url for url in captured_url) + + class TestListTools: def test_list(self, tmp_path: Path) -> None: with patch.object(install_tools, "TARGET_DIR", tmp_path): @@ -341,6 +382,11 @@ class TestInstallTool: assert install_tools._install_tool("vale") is True mock.assert_called_once() + def test_promtool(self) -> None: + with patch.object(install_tools, "install_promtool", return_value=True) as mock: + assert install_tools._install_tool("promtool") is True + mock.assert_called_once() + def test_unknown_tool(self) -> None: with pytest.raises(ClickException, match="Unknown tool"): install_tools._install_tool("unknown") @@ -359,7 +405,7 @@ class TestMain: with patch.object(install_tools, "_install_tool", return_value=True) as mock_install: result = runner.invoke(install_tools.main, []) assert result.exit_code == 0 - assert mock_install.call_count == 7 + assert mock_install.call_count == 8 def test_install_specific_tool(self) -> None: runner = CliRunner()