diff --git a/Makefile b/Makefile index 2b8bb55..599cdb9 100644 --- a/Makefile +++ b/Makefile @@ -20,6 +20,12 @@ DEVX_TEST_PATHS := tests/ DEVX_MAK := $(shell $(BIN)/python -c \ "from pathlib import Path; import devx; print(Path(devx.__file__).parent / 'make' / 'devx.mak')" \ 2>/dev/null) +# Fallback: when the venv doesn't exist yet (chicken-and-egg), use the +# source tree copy directly. devx IS the package, so src/devx/make/devx.mak +# is always available in this repo. +ifeq ($(strip $(DEVX_MAK)),) +DEVX_MAK := $(CURDIR)/src/devx/make/devx.mak +endif -include $(DEVX_MAK) # venv, .env, and activate-scripts are provided by devx.mak diff --git a/src/devx/tools/check_deps.py b/src/devx/tools/check_deps.py new file mode 100644 index 0000000..af533e8 --- /dev/null +++ b/src/devx/tools/check_deps.py @@ -0,0 +1,105 @@ +#!/usr/bin/env python3 +"""Check that required development tools are present. + +Verifies the availability of core tools (tofu, docker, checkmake, Python +3.12+ in the venv) and prints warnings or errors for missing ones. + +Usage:: + + python3 -m devx.tools.check_deps + python3 -m devx.tools.check_deps --venv .venv +""" + +from __future__ import annotations + +import shutil +import subprocess # nosec B404 +from pathlib import Path + +import click + +from devx.i18n import _ + +REQUIRED_TOOLS = ["tofu", "docker"] +OPTIONAL_TOOLS = ["checkmake"] +PYTHON_MIN_VERSION = (3, 12) + + +def _check_tool(name: str, *, optional: bool = False) -> bool: + """Check if a tool is on PATH. Returns True if found.""" + found = shutil.which(name) is not None + if found: + return True + level = "WARN" if optional else "ERROR" + click.echo( + _("{level}: {tool} not found.{hint}", level=level, tool=name, hint=""), + err=True, + ) + return False + + +def _check_python_version(venv_bin: Path) -> None: + """Check that the venv Python is >= 3.12.""" + python_bin = venv_bin / "python" + if not python_bin.exists(): + click.echo( + _("WARN: .venv not found. Run 'make setup-venv' to create it."), + err=True, + ) + return + result = subprocess.run( # nosec B603 + [str(python_bin), "--version"], + capture_output=True, + text=True, + check=False, + ) + if result.returncode != 0: + click.echo(_("WARN: Could not determine Python version in .venv."), err=True) + return + version_str = result.stdout.strip().split()[-1] if result.stdout else "" + try: + major, minor = int(version_str.split(".")[0]), int(version_str.split(".")[1]) + except (IndexError, ValueError): + click.echo(_("WARN: Could not parse Python version '{version}'.", version=version_str), err=True) + return + if (major, minor) < PYTHON_MIN_VERSION: + click.echo( + _( + "WARN: .venv has Python {version}, but >={req} is required.", + version=version_str, + req=f"{PYTHON_MIN_VERSION[0]}.{PYTHON_MIN_VERSION[1]}", + ), + err=True, + ) + return + click.echo(_("[check-deps] Virtualenv .venv ready (Python {version}).", version=version_str)) + + +@click.command() +@click.option("--venv", default=".venv", show_default=True, help="Path to the virtual environment.") +@click.option("--checkmake-bin", default=None, help="Path to checkmake binary (fallback if not on PATH).") +def cli(venv: str, checkmake_bin: str | None) -> None: + """Verify that required development tools are present.""" + click.echo("[check-deps] Verifying tools...") + + all_required = True + for tool in REQUIRED_TOOLS: + if not _check_tool(tool): + all_required = False + + for tool in OPTIONAL_TOOLS: + if not _check_tool(tool, optional=True): + if checkmake_bin and Path(checkmake_bin).exists(): + click.echo(f" {tool}: found at {checkmake_bin}") + else: + click.echo(" Run 'make install-checkmake' to install the Makefile linter.") + + _check_python_version(Path(venv) / "bin") + + if not all_required: + raise click.ClickException("Required tools missing.") + click.echo("[check-deps] All core tools present.") + + +if __name__ == "__main__": # pragma: no cover + cli() # pragma: no cover diff --git a/src/devx/tools/docker_login.py b/src/devx/tools/docker_login.py new file mode 100644 index 0000000..9e2a77a --- /dev/null +++ b/src/devx/tools/docker_login.py @@ -0,0 +1,123 @@ +#!/usr/bin/env python3 +"""Docker registry login helper. + +Handles login to Docker registries (Gitea, Docker Hub) with credential +loading from environment variables. Supports required and optional modes. + +Usage:: + + python3 -m devx.tools.docker_login --registry git.oblachno.oblachno.fyi \\ + --token-env CI_GITEA_TOKEN --username-env CI_GITEA_USERNAME \\ + --default-username emil + + python3 -m devx.tools.docker_login --registry docker.io \\ + --token-env DOCKER_HUB_TOKEN --username-env DOCKER_HUB_USERNAME --optional +""" + +from __future__ import annotations + +import subprocess # nosec B404 + +import click + +from devx.i18n import _ + + +def docker_login( + registry: str, + username: str, + token: str, + *, + suppress_failure: bool = False, +) -> bool: + """Log in to a Docker registry. + + Returns True on success, False on failure. + If ``suppress_failure`` is True, prints a warning instead of raising. + """ + cmd = ["docker", "login", registry, "-u", username, "-p", token] + result = subprocess.run( # nosec B603 + cmd, + capture_output=True, + text=True, + check=False, + ) + if result.returncode != 0: + if suppress_failure: + click.echo( + _("[docker-login] Login to {registry} failed (continuing).", registry=registry), + err=True, + ) + return False + raise click.ClickException( + _("Login to {registry} failed: {error}", registry=registry, error=result.stderr.strip()), + ) + click.echo(_("[docker-login] Logged in to {registry}.", registry=registry)) + return True + + +def _resolve_credentials( + token_env: str, + username_env: str, + default_username: str | None, +) -> tuple[str | None, str | None]: + """Resolve credentials from environment variables. + + Returns (username, token) or (None, None) if token is not set. + """ + import os + + token = os.environ.get(token_env, "") + if not token: + return None, None + username = os.environ.get(username_env, "") or (default_username or "") + return username, token + + +@click.command() +@click.option("--registry", required=True, help="Docker registry URL (e.g. docker.io, git.example.com).") +@click.option("--token-env", required=True, help="Environment variable name for the auth token.") +@click.option("--username-env", required=True, help="Environment variable name for the username.") +@click.option( + "--default-username", + default=None, + help="Default username if the env var is not set.", +) +@click.option( + "--optional", + is_flag=True, + default=False, + help="Skip silently if token is not set instead of raising.", +) +@click.option( + "--suppress-failure", + is_flag=True, + default=False, + help="Continue on login failure instead of raising (prints warning).", +) +def cli( + registry: str, + token_env: str, + username_env: str, + default_username: str | None, + optional: bool, + suppress_failure: bool, +) -> None: + """Log in to a Docker registry using credentials from environment variables.""" + username, token = _resolve_credentials(token_env, username_env, default_username) + if token is None: + if optional: + click.echo(_("[docker-login] Skipping {registry} (token {env} not set).", registry=registry, env=token_env)) + return + raise click.ClickException( + _("{env} is not set. Set it in your .env file or pass it as an environment variable.", env=token_env), + ) + if not username: + raise click.ClickException( + _("{env} is not set. Set it in your .env file.", env=username_env), + ) + docker_login(registry, username, token, suppress_failure=suppress_failure) + + +if __name__ == "__main__": # pragma: no cover + cli() # pragma: no cover diff --git a/src/devx/tools/install_tools.py b/src/devx/tools/install_tools.py index 6dd9893..323eef5 100644 --- a/src/devx/tools/install_tools.py +++ b/src/devx/tools/install_tools.py @@ -42,6 +42,8 @@ TEA_VERSION = "0.14.1" HADOLINT_VERSION = "2.12.0" +TOFU_VERSION = "1.12.3" + def _arch() -> str: """Return the architecture string used by release assets (delegates to shared utility).""" @@ -174,7 +176,27 @@ def install_hadolint() -> bool: return True -TOOL_NAMES = ["actionlint", "git-cliff", "act_runner", "tea", "hadolint"] +def install_tofu() -> bool: + """Install OpenTofu if not already present. Returns True if installed/skipped. + + Downloads the official release tarball from GitHub and extracts the + ``tofu`` binary to ``~/.local/bin``. + """ + if _is_installed("tofu"): + click.echo("tofu: already installed") + return True + arch = _arch() + os_name = platform.system().lower() + url = ( + f"https://github.com/opentofu/opentofu/releases/download/" + f"v{TOFU_VERSION}/tofu_{TOFU_VERSION}_{os_name}_{arch}.tar.gz" + ) + dest = _download_and_extract_tarball(url, "tofu") + click.echo(f"tofu: installed to {dest}") + return True + + +TOOL_NAMES = ["actionlint", "git-cliff", "act_runner", "tea", "hadolint", "tofu"] def _install_tool(name: str) -> bool: @@ -189,6 +211,8 @@ def _install_tool(name: str) -> bool: return install_tea() if name == "hadolint": return install_hadolint() + if name == "tofu": + return install_tofu() raise click.ClickException(f"Unknown tool: {name}") diff --git a/src/devx/tools/tofu_ops.py b/src/devx/tools/tofu_ops.py new file mode 100644 index 0000000..ccbbd34 --- /dev/null +++ b/src/devx/tools/tofu_ops.py @@ -0,0 +1,119 @@ +#!/usr/bin/env python3 +"""OpenTofu operations: init and validate across directories. + +Handles initialization and validation of OpenTofu configurations across +multiple directories (modules + environments). Supports CI mode with +``-backend=false`` to avoid state backend access. + +Usage:: + + python3 -m devx.tools.tofu_ops init --env staging + python3 -m devx.tools.tofu_ops validate + python3 -m devx.tools.tofu_ops validate --ci +""" + +from __future__ import annotations + +import subprocess # nosec B404 +from pathlib import Path + +import click + +from devx.i18n import _ + +DEFAULT_ENV_DIRS = ["tofu/environments/{env}", "tofu/environments/dns"] +DEFAULT_VALIDATE_DIRS = [ + "tofu/modules/hetzner-vm", + "tofu/modules/hetzner-network", + "tofu/environments/staging", + "tofu/environments/production", + "tofu/environments/dns", +] + + +def _run_tofu(cmd: list[str], cwd: Path) -> None: + """Run a tofu command in the given directory, raising on failure.""" + click.echo(f" -> {cwd}") + result = subprocess.run( # nosec B603, B607 + cmd, + cwd=str(cwd), + capture_output=True, + text=True, + check=False, + ) + if result.returncode != 0: + raise click.ClickException( + _("tofu command failed in {dir}: {error}", dir=cwd, error=result.stderr.strip()), + ) + + +def tofu_init(env: str, root: str = ".", extra_dirs: list[str] | None = None) -> None: + """Run ``tofu init`` in the environment directory and DNS directory. + + Args: + env: Environment name (e.g. staging, production). + root: Repository root directory. + extra_dirs: Additional directory patterns to initialize. + """ + root_path = Path(root) + dirs = [d.format(env=env) for d in (extra_dirs or DEFAULT_ENV_DIRS)] + for dir_pattern in dirs: + dir_path = root_path / dir_pattern + if dir_path.is_dir(): + click.echo(f"[tofu-init] Initializing {dir_path}...") + _run_tofu(["tofu", "init"], dir_path) + click.echo("[tofu-init] Done.") + + +def tofu_validate( + root: str = ".", + dirs: list[str] | None = None, + ci: bool = False, +) -> None: + """Run ``tofu validate`` in all OpenTofu directories. + + In CI mode, runs ``tofu init -backend=false`` before validate to avoid + state backend access. + + Args: + root: Repository root directory. + dirs: List of directory paths to validate (relative to root). + ci: If True, use CI mode with -backend=false. + """ + root_path = Path(root) + target_dirs = dirs or DEFAULT_VALIDATE_DIRS + mode = "ci" if ci else "validate" + click.echo(f"[tofu-{mode}] Validating OpenTofu configurations...") + for dir_rel in target_dirs: + dir_path = root_path / dir_rel + if not dir_path.is_dir(): + continue + if ci: + _run_tofu(["tofu", "init", "-backend=false", "-input=false"], dir_path) + _run_tofu(["tofu", "validate"], dir_path) + click.echo(f"[tofu-{mode}] All configurations valid.") + + +@click.group() +def cli() -> None: + """OpenTofu operations.""" + + +@cli.command() +@click.option("--env", required=True, help="Environment name (staging, production).") +@click.option("--root", default=".", help="Repository root directory.") +def init(env: str, root: str) -> None: + """Initialize OpenTofu in an environment.""" + tofu_init(env, root) + + +@cli.command() +@click.option("--root", default=".", help="Repository root directory.") +@click.option("--ci", is_flag=True, default=False, help="CI mode: use -backend=false.") +def validate(root: str, ci: bool) -> None: + """Validate OpenTofu configurations.""" + tofu_validate(root, ci=ci) + + +if __name__ == "__main__": # pragma: no cover + cli() # pragma: no cover diff --git a/src/devx/translations.json b/src/devx/translations.json index 6c05415..a43ee19 100644 --- a/src/devx/translations.json +++ b/src/devx/translations.json @@ -3094,5 +3094,109 @@ "pl": " - {count} standard labels verified", "ru": " - {count} standard labels verified", "zh": " - {count} standard labels verified" + }, + "[check-deps] Virtualenv .venv ready (Python {version}).": { + "en": "[check-deps] Virtualenv .venv ready (Python {version}).", + "bg": "[check-deps] Виртуална среда .venv готова (Python {version}).", + "de": "[check-deps] Virtuelle Umgebung .venv bereit (Python {version}).", + "pl": "[check-deps] Środowisko wirtualne .venv gotowe (Python {version}).", + "ru": "[check-deps] Виртуальное окружение .venv готово (Python {version}).", + "zh": "[check-deps] 虚拟环境 .venv 已就绪 (Python {version})。" + }, + "{level}: {tool} not found.{hint}": { + "en": "{level}: {tool} not found.{hint}", + "bg": "{level}: {tool} не е намерен.{hint}", + "de": "{level}: {tool} nicht gefunden.{hint}", + "pl": "{level}: {tool} nie znaleziono.{hint}", + "ru": "{level}: {tool} не найден.{hint}", + "zh": "{level}: 未找到 {tool}。{hint}" + }, + "WARN: Could not determine Python version in .venv.": { + "en": "WARN: Could not determine Python version in .venv.", + "bg": "ПРЕДУПРЕЖДЕНИЕ: Не може да се определи версията на Python в .venv.", + "de": "WARNUNG: Python-Version in .venv konnte nicht bestimmt werden.", + "pl": "OSTRZEŻENIE: Nie można określić wersji Python w .venv.", + "ru": "ПРЕДУПРЕЖДЕНИЕ: Не удалось определить версию Python в .venv.", + "zh": "警告: 无法确定 .venv 中的 Python 版本。" + }, + "WARN: Could not parse Python version '{version}'.": { + "en": "WARN: Could not parse Python version '{version}'.", + "bg": "ПРЕДУПРЕЖДЕНИЕ: Не може да се анализира версията на Python '{version}'.", + "de": "WARNUNG: Python-Version '{version}' konnte nicht analysiert werden.", + "pl": "OSTRZEŻENIE: Nie można przeanalizować wersji Python '{version}'.", + "ru": "ПРЕДУПРЕЖДЕНИЕ: Не удалось разобрать версию Python '{version}'.", + "zh": "警告: 无法解析 Python 版本 '{version}'。" + }, + "WARN: .venv not found. Run 'make setup-venv' to create it.": { + "en": "WARN: .venv not found. Run 'make setup-venv' to create it.", + "bg": "ПРЕДУПРЕЖДЕНИЕ: .venv не е намерен. Изпълнете 'make setup-venv' за създаване.", + "de": "WARNUNG: .venv nicht gefunden. Führen Sie 'make setup-venv' aus, um es zu erstellen.", + "pl": "OSTRZEŻENIE: Nie znaleziono .venv. Uruchom 'make setup-venv', aby utworzyć.", + "ru": "ПРЕДУПРЕЖДЕНИЕ: .venv не найден. Выполните 'make setup-venv' для создания.", + "zh": "警告: 未找到 .venv。运行 'make setup-venv' 来创建。" + }, + "[docker-login] Logged in to {registry}.": { + "en": "[docker-login] Logged in to {registry}.", + "bg": "[docker-login] Влязъл в {registry}.", + "de": "[docker-login] Angemeldet bei {registry}.", + "pl": "[docker-login] Zalogowano do {registry}.", + "ru": "[docker-login] Выполнен вход в {registry}.", + "zh": "[docker-login] 已登录到 {registry}。" + }, + "[docker-login] Login to {registry} failed (continuing).": { + "en": "[docker-login] Login to {registry} failed (continuing).", + "bg": "[docker-login] Влизането в {registry} не успя (продължава).", + "de": "[docker-login] Anmeldung bei {registry} fehlgeschlagen (wird fortgesetzt).", + "pl": "[docker-login] Logowanie do {registry} nie powiodło się (kontynuowanie).", + "ru": "[docker-login] Ошибка входа в {registry} (продолжаем).", + "zh": "[docker-login] 登录 {registry} 失败(继续)。" + }, + "[docker-login] Skipping {registry} (token {env} not set).": { + "en": "[docker-login] Skipping {registry} (token {env} not set).", + "bg": "[docker-login] Пропускане на {registry} (токен {env} не е зададен).", + "de": "[docker-login] {registry} übersprungen (Token {env} nicht gesetzt).", + "pl": "[docker-login] Pomijanie {registry} (token {env} nie ustawiony).", + "ru": "[docker-login] Пропуск {registry} (токен {env} не задан).", + "zh": "[docker-login] 跳过 {registry}(未设置令牌 {env})。" + }, + "{env} is not set. Set it in your .env file.": { + "en": "{env} is not set. Set it in your .env file.", + "bg": "{env} не е зададен. Задайте го във вашия .env файл.", + "de": "{env} ist nicht gesetzt. Setzen Sie es in Ihrer .env-Datei.", + "pl": "{env} nie jest ustawiony. Ustaw go w pliku .env.", + "ru": "{env} не задан. Установите его в файле .env.", + "zh": "{env} 未设置。请在 .env 文件中设置。" + }, + "{env} is not set. Set it in your .env file or pass it as an environment variable.": { + "en": "{env} is not set. Set it in your .env file or pass it as an environment variable.", + "bg": "{env} не е зададен. Задайте го във вашия .env файл или го подайте като променлива на средата.", + "de": "{env} ist nicht gesetzt. Setzen Sie es in Ihrer .env-Datei oder übergeben Sie es als Umgebungsvariable.", + "pl": "{env} nie jest ustawiony. Ustaw go w pliku .env lub przekaż jako zmienną środowiskową.", + "ru": "{env} не задан. Установите его в файле .env или передайте как переменную окружения.", + "zh": "{env} 未设置。请在 .env 文件中设置或作为环境变量传递。" + }, + "Login to {registry} failed: {error}": { + "en": "Login to {registry} failed: {error}", + "bg": "Влизането в {registry} не успя: {error}", + "de": "Anmeldung bei {registry} fehlgeschlagen: {error}", + "pl": "Logowanie do {registry} nie powiodło się: {error}", + "ru": "Ошибка входа в {registry}: {error}", + "zh": "登录 {registry} 失败: {error}" + }, + "tofu command failed in {dir}: {error}": { + "en": "tofu command failed in {dir}: {error}", + "bg": "командата tofu не успя в {dir}: {error}", + "de": "tofu-Befehl fehlgeschlagen in {dir}: {error}", + "pl": "polecenie tofu nie powiodło się w {dir}: {error}", + "ru": "команда tofu не удалась в {dir}: {error}", + "zh": "tofu 命令在 {dir} 中失败: {error}" + }, + "WARN: .venv has Python {version}, but >={req} is required.": { + "en": "WARN: .venv has Python {version}, but >={req} is required.", + "bg": "ПРЕДУПРЕЖДЕНИЕ: .venv има Python {version}, но се изисква >={req}.", + "de": "WARNUNG: .venv hat Python {version}, aber >={req} ist erforderlich.", + "pl": "OSTRZEŻENIE: .venv ma Python {version}, ale wymagane jest >={req}.", + "ru": "ПРЕДУПРЕЖДЕНИЕ: в .venv установлен Python {version}, но требуется >={req}.", + "zh": "警告: .venv 的 Python 版本为 {version},但要求 >={req}。" } } diff --git a/tests/unit/test_check_deps.py b/tests/unit/test_check_deps.py new file mode 100644 index 0000000..a2773d2 --- /dev/null +++ b/tests/unit/test_check_deps.py @@ -0,0 +1,108 @@ +"""Unit tests for devx.tools.check_deps.""" + +from pathlib import Path +from unittest.mock import MagicMock, patch + +from click.testing import CliRunner + +from devx.tools.check_deps import ( + _check_python_version, + _check_tool, + cli, +) + + +class TestCheckTool: + @patch("devx.tools.check_deps.shutil.which", return_value="/usr/bin/tofu") + def test_found(self, mock_which: MagicMock) -> None: + assert _check_tool("tofu") is True + + @patch("devx.tools.check_deps.shutil.which", return_value=None) + def test_not_found_required(self, mock_which: MagicMock) -> None: + assert _check_tool("tofu") is False + + @patch("devx.tools.check_deps.shutil.which", return_value=None) + def test_not_found_optional(self, mock_which: MagicMock) -> None: + assert _check_tool("checkmake", optional=True) is False + + +class TestCheckPythonVersion: + @patch("devx.tools.check_deps.subprocess.run") + def test_valid_version(self, mock_run: MagicMock, tmp_path: Path) -> None: + venv_bin = tmp_path / "bin" + venv_bin.mkdir() + (venv_bin / "python").touch() + mock_run.return_value = MagicMock(returncode=0, stdout="Python 3.12.3\n", stderr="") + _check_python_version(venv_bin) + + @patch("devx.tools.check_deps.subprocess.run") + def test_old_version(self, mock_run: MagicMock, tmp_path: Path) -> None: + venv_bin = tmp_path / "bin" + venv_bin.mkdir() + (venv_bin / "python").touch() + mock_run.return_value = MagicMock(returncode=0, stdout="Python 3.11.0\n", stderr="") + _check_python_version(venv_bin) + + def test_no_venv(self, tmp_path: Path) -> None: + venv_bin = tmp_path / "bin" + _check_python_version(venv_bin) + + @patch("devx.tools.check_deps.subprocess.run") + def test_command_fails(self, mock_run: MagicMock, tmp_path: Path) -> None: + venv_bin = tmp_path / "bin" + venv_bin.mkdir() + (venv_bin / "python").touch() + mock_run.return_value = MagicMock(returncode=1, stdout="", stderr="error") + _check_python_version(venv_bin) + + @patch("devx.tools.check_deps.subprocess.run") + def test_unparseable_version(self, mock_run: MagicMock, tmp_path: Path) -> None: + venv_bin = tmp_path / "bin" + venv_bin.mkdir() + (venv_bin / "python").touch() + mock_run.return_value = MagicMock(returncode=0, stdout="garbage\n", stderr="") + _check_python_version(venv_bin) + + +class TestCli: + @patch("devx.tools.check_deps._check_python_version") + @patch("devx.tools.check_deps._check_tool") + def test_all_present(self, mock_check: MagicMock, mock_py: MagicMock) -> None: + mock_check.return_value = True + runner = CliRunner() + result = runner.invoke(cli, []) + assert result.exit_code == 0 + assert "All core tools present" in result.output + + @patch("devx.tools.check_deps._check_python_version") + @patch("devx.tools.check_deps._check_tool") + def test_missing_required(self, mock_check: MagicMock, mock_py: MagicMock) -> None: + mock_check.side_effect = lambda name, optional=False: name != "tofu" + runner = CliRunner() + result = runner.invoke(cli, []) + assert result.exit_code != 0 + + @patch("devx.tools.check_deps._check_python_version") + @patch("devx.tools.check_deps._check_tool") + def test_missing_optional_with_fallback(self, mock_check: MagicMock, mock_py: MagicMock, tmp_path: Path) -> None: + checkmake_bin = tmp_path / "checkmake" + checkmake_bin.touch() + + def _side(name: str, optional: bool = False) -> bool: + return name != "checkmake" + + mock_check.side_effect = _side + runner = CliRunner() + result = runner.invoke(cli, ["--checkmake-bin", str(checkmake_bin)]) + assert result.exit_code == 0 + + @patch("devx.tools.check_deps._check_python_version") + @patch("devx.tools.check_deps._check_tool") + def test_missing_optional_no_fallback(self, mock_check: MagicMock, mock_py: MagicMock) -> None: + def _side(name: str, optional: bool = False) -> bool: + return name != "checkmake" + + mock_check.side_effect = _side + runner = CliRunner() + result = runner.invoke(cli, []) + assert result.exit_code == 0 diff --git a/tests/unit/test_docker_login.py b/tests/unit/test_docker_login.py new file mode 100644 index 0000000..24d004f --- /dev/null +++ b/tests/unit/test_docker_login.py @@ -0,0 +1,149 @@ +"""Unit tests for devx.tools.docker_login.""" + +from unittest.mock import MagicMock, patch + +import pytest +from click.testing import CliRunner + +from devx.tools.docker_login import ( + _resolve_credentials, + cli, + docker_login, +) + + +class TestDockerLogin: + @patch("devx.tools.docker_login.subprocess.run") + def test_success(self, mock_run: MagicMock) -> None: + mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="") + assert docker_login("registry.io", "user", "tok") is True + + @patch("devx.tools.docker_login.subprocess.run") + def test_failure_raises(self, mock_run: MagicMock) -> None: + mock_run.return_value = MagicMock(returncode=1, stdout="", stderr="auth failed") + with pytest.raises(Exception, match="auth failed"): + docker_login("registry.io", "user", "tok") + + @patch("devx.tools.docker_login.subprocess.run") + def test_failure_suppressed(self, mock_run: MagicMock) -> None: + mock_run.return_value = MagicMock(returncode=1, stdout="", stderr="auth failed") + assert docker_login("registry.io", "user", "tok", suppress_failure=True) is False + + +class TestResolveCredentials: + @patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok", "CI_GITEA_USERNAME": "emil"}, clear=True) + def test_both_set(self) -> None: + user, token = _resolve_credentials("CI_GITEA_TOKEN", "CI_GITEA_USERNAME", None) + assert user == "emil" + assert token == "tok" + + @patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"}, clear=True) + def test_token_only_with_default(self) -> None: + user, token = _resolve_credentials("CI_GITEA_TOKEN", "CI_GITEA_USERNAME", "emil") + assert user == "emil" + assert token == "tok" + + @patch.dict("os.environ", {}, clear=True) + def test_no_token(self) -> None: + user, token = _resolve_credentials("CI_GITEA_TOKEN", "CI_GITEA_USERNAME", "emil") + assert user is None + assert token is None + + @patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"}, clear=True) + def test_no_username_no_default(self) -> None: + user, token = _resolve_credentials("CI_GITEA_TOKEN", "CI_GITEA_USERNAME", None) + assert user == "" + assert token == "tok" + + +class TestCli: + @patch("devx.tools.docker_login.docker_login") + @patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok", "CI_GITEA_USERNAME": "emil"}, clear=True) + def test_required_login(self, mock_login: MagicMock) -> None: + mock_login.return_value = True + runner = CliRunner() + result = runner.invoke( + cli, + ["--registry", "reg.io", "--token-env", "CI_GITEA_TOKEN", "--username-env", "CI_GITEA_USERNAME"], + ) + assert result.exit_code == 0 + mock_login.assert_called_once() + + @patch("devx.tools.docker_login.docker_login") + @patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"}, clear=True) + def test_default_username(self, mock_login: MagicMock) -> None: + mock_login.return_value = True + runner = CliRunner() + result = runner.invoke( + cli, + [ + "--registry", + "reg.io", + "--token-env", + "CI_GITEA_TOKEN", + "--username-env", + "CI_GITEA_USERNAME", + "--default-username", + "emil", + ], + ) + assert result.exit_code == 0 + mock_login.assert_called_once_with("reg.io", "emil", "tok", suppress_failure=False) + + @patch.dict("os.environ", {}, clear=True) + def test_required_no_token_raises(self) -> None: + runner = CliRunner() + result = runner.invoke( + cli, + ["--registry", "reg.io", "--token-env", "CI_GITEA_TOKEN", "--username-env", "CI_GITEA_USERNAME"], + ) + assert result.exit_code != 0 + + @patch.dict("os.environ", {}, clear=True) + def test_optional_no_token_skips(self) -> None: + runner = CliRunner() + result = runner.invoke( + cli, + [ + "--registry", + "reg.io", + "--token-env", + "CI_GITEA_TOKEN", + "--username-env", + "CI_GITEA_USERNAME", + "--optional", + ], + ) + assert result.exit_code == 0 + assert "Skipping" in result.output + + @patch("devx.tools.docker_login.docker_login") + @patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"}, clear=True) + def test_no_username_raises(self, mock_login: MagicMock) -> None: + runner = CliRunner() + result = runner.invoke( + cli, + ["--registry", "reg.io", "--token-env", "CI_GITEA_TOKEN", "--username-env", "CI_GITEA_USERNAME"], + ) + assert result.exit_code != 0 + mock_login.assert_not_called() + + @patch("devx.tools.docker_login.docker_login") + @patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok", "CI_GITEA_USERNAME": "emil"}, clear=True) + def test_suppress_failure(self, mock_login: MagicMock) -> None: + mock_login.return_value = False + runner = CliRunner() + result = runner.invoke( + cli, + [ + "--registry", + "reg.io", + "--token-env", + "CI_GITEA_TOKEN", + "--username-env", + "CI_GITEA_USERNAME", + "--suppress-failure", + ], + ) + assert result.exit_code == 0 + mock_login.assert_called_once_with("reg.io", "emil", "tok", suppress_failure=True) diff --git a/tests/unit/test_install_tools.py b/tests/unit/test_install_tools.py index f7dcf1c..b1f40dd 100644 --- a/tests/unit/test_install_tools.py +++ b/tests/unit/test_install_tools.py @@ -240,6 +240,35 @@ class TestInstallHadolint: assert (tmp_path / "hadolint").exists() +class TestInstallTofu: + def test_already_installed(self) -> None: + with patch.object(install_tools, "_is_installed", return_value=True): + assert install_tools.install_tofu() 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 tofu" + with tarfile.open(tarball_path, "w:gz") as tar: + info = tarfile.TarInfo(name="tofu") + 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(platform, "machine", return_value="x86_64"): + with patch.object(platform, "system", return_value="Linux"): + with patch.object( + install_tools, + "_download", + side_effect=lambda url, dest: Path(dest).write_bytes(tarball_path.read_bytes()), + ): + assert install_tools.install_tofu() is True + assert (tmp_path / "tofu").exists() + + class TestListTools: def test_list(self, tmp_path: Path) -> None: with patch.object(install_tools, "TARGET_DIR", tmp_path): @@ -274,6 +303,11 @@ class TestInstallTool: assert install_tools._install_tool("hadolint") is True mock.assert_called_once() + def test_tofu(self) -> None: + with patch.object(install_tools, "install_tofu", return_value=True) as mock: + assert install_tools._install_tool("tofu") is True + mock.assert_called_once() + def test_unknown_tool(self) -> None: with pytest.raises(ClickException, match="Unknown tool"): install_tools._install_tool("unknown") @@ -292,7 +326,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 == 5 + assert mock_install.call_count == 6 def test_install_specific_tool(self) -> None: runner = CliRunner() diff --git a/tests/unit/test_tofu_ops.py b/tests/unit/test_tofu_ops.py new file mode 100644 index 0000000..a90fd44 --- /dev/null +++ b/tests/unit/test_tofu_ops.py @@ -0,0 +1,115 @@ +"""Unit tests for devx.tools.tofu_ops.""" + +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest +from click.testing import CliRunner + +from devx.tools.tofu_ops import ( + _run_tofu, + cli, + tofu_init, + tofu_validate, +) + + +class TestRunTofu: + @patch("devx.tools.tofu_ops.subprocess.run") + def test_success(self, mock_run: MagicMock, tmp_path: Path) -> None: + mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="") + _run_tofu(["tofu", "init"], tmp_path) + mock_run.assert_called_once() + + @patch("devx.tools.tofu_ops.subprocess.run") + def test_failure_raises(self, mock_run: MagicMock, tmp_path: Path) -> None: + mock_run.return_value = MagicMock(returncode=1, stdout="", stderr="error") + with pytest.raises(Exception, match="error"): + _run_tofu(["tofu", "validate"], tmp_path) + + +class TestTofuInit: + @patch("devx.tools.tofu_ops._run_tofu") + def test_init_existing_dirs(self, mock_run: MagicMock, tmp_path: Path) -> None: + (tmp_path / "tofu/environments/staging").mkdir(parents=True) + (tmp_path / "tofu/environments/dns").mkdir(parents=True) + tofu_init("staging", root=str(tmp_path)) + assert mock_run.call_count == 2 + + @patch("devx.tools.tofu_ops._run_tofu") + def test_init_skips_missing_dirs(self, mock_run: MagicMock, tmp_path: Path) -> None: + (tmp_path / "tofu/environments/staging").mkdir(parents=True) + # dns dir doesn't exist + tofu_init("staging", root=str(tmp_path)) + assert mock_run.call_count == 1 + + @patch("devx.tools.tofu_ops._run_tofu") + def test_init_no_dirs_exist(self, mock_run: MagicMock, tmp_path: Path) -> None: + tofu_init("staging", root=str(tmp_path)) + mock_run.assert_not_called() + + @patch("devx.tools.tofu_ops._run_tofu") + def test_init_custom_dirs(self, mock_run: MagicMock, tmp_path: Path) -> None: + (tmp_path / "custom/dir").mkdir(parents=True) + tofu_init("staging", root=str(tmp_path), extra_dirs=["custom/dir"]) + assert mock_run.call_count == 1 + + +class TestTofuValidate: + @patch("devx.tools.tofu_ops._run_tofu") + def test_validate_all_dirs(self, mock_run: MagicMock, tmp_path: Path) -> None: + for d in [ + "tofu/modules/hetzner-vm", + "tofu/modules/hetzner-network", + "tofu/environments/staging", + "tofu/environments/production", + "tofu/environments/dns", + ]: + (tmp_path / d).mkdir(parents=True) + tofu_validate(root=str(tmp_path)) + assert mock_run.call_count == 5 + + @patch("devx.tools.tofu_ops._run_tofu") + def test_validate_skips_missing(self, mock_run: MagicMock, tmp_path: Path) -> None: + (tmp_path / "tofu/environments/staging").mkdir(parents=True) + tofu_validate(root=str(tmp_path)) + assert mock_run.call_count == 1 + + @patch("devx.tools.tofu_ops._run_tofu") + def test_validate_ci_mode(self, mock_run: MagicMock, tmp_path: Path) -> None: + (tmp_path / "tofu/environments/staging").mkdir(parents=True) + tofu_validate(root=str(tmp_path), ci=True) + # CI mode runs init + validate = 2 calls per dir + assert mock_run.call_count == 2 + first_call = mock_run.call_args_list[0][0][0] + assert "init" in first_call + assert "-backend=false" in first_call + + @patch("devx.tools.tofu_ops._run_tofu") + def test_validate_custom_dirs(self, mock_run: MagicMock, tmp_path: Path) -> None: + (tmp_path / "custom").mkdir() + tofu_validate(root=str(tmp_path), dirs=["custom"]) + assert mock_run.call_count == 1 + + +class TestCli: + @patch("devx.tools.tofu_ops.tofu_init") + def test_init_command(self, mock_init: MagicMock) -> None: + runner = CliRunner() + result = runner.invoke(cli, ["init", "--env", "staging"]) + assert result.exit_code == 0 + mock_init.assert_called_once_with("staging", ".") + + @patch("devx.tools.tofu_ops.tofu_validate") + def test_validate_command(self, mock_validate: MagicMock) -> None: + runner = CliRunner() + result = runner.invoke(cli, ["validate"]) + assert result.exit_code == 0 + mock_validate.assert_called_once_with(".", ci=False) + + @patch("devx.tools.tofu_ops.tofu_validate") + def test_validate_ci_command(self, mock_validate: MagicMock) -> None: + runner = CliRunner() + result = runner.invoke(cli, ["validate", "--ci"]) + assert result.exit_code == 0 + mock_validate.assert_called_once_with(".", ci=True)