diff --git a/modules/helpers.py b/modules/helpers.py index 1bbe2f0..d4e1d35 100644 --- a/modules/helpers.py +++ b/modules/helpers.py @@ -7,6 +7,7 @@ import json import os +import platform import re import shutil import subprocess @@ -23,7 +24,6 @@ from modules.config_loader import load_config from modules.provider_detector import get_provider_for_resource - # When True, pretty_name() returns the raw Terraform resource name unchanged. # Set by the CLI --use-tf-names flag. USE_TF_NAMES: bool = False @@ -1759,6 +1759,27 @@ def safe_remove_connection( return False +def _get_os_family() -> str: + """Detect the operating system family for install instructions.""" + system = platform.system() + if system == "Darwin": + return "macos" + elif system == "Linux": + if is_wsl(): + return "wsl" + try: + with open("/etc/os-release") as f: + content = f.read().lower() + if "ubuntu" in content or "debian" in content: + return "debian" + except OSError: + pass + return "linux" + elif system == "Windows": + return "windows" + return "unknown" + + # Resolved Terraform-compatible binary: 'terraform' or 'tofu' (OpenTofu). # Set by the CLI --engine flag via set_tf_binary(); defaults to autodetection. _TF_BINARY: Optional[str] = None @@ -1800,26 +1821,106 @@ def get_tf_binary() -> str: return _TF_BINARY +# install commands are looked up by OS family, falling back to "default". +# Lines starting with "#" are printed verbatim as guidance/doc links. +# The terraform entry's executable is resolved at call time via +# get_tf_binary(), so it follows the --engine flag (terraform or tofu). +DEPENDENCIES: Dict[str, Dict[str, Any]] = { + "graphviz": { + "name": "Graphviz", + "executables": ["dot", "gvpr"], + "install": { + "macos": ["brew install graphviz"], + "debian": ["sudo apt update", "sudo apt install graphviz"], + "wsl": ["sudo apt update", "sudo apt install graphviz"], + "windows": ["# Graphviz: https://graphviz.org/download/"], + "default": [ + "# Install Graphviz using your package manager:", + "# sudo apt install graphviz (Debian/Ubuntu)", + "# sudo dnf install graphviz (Fedora/RHEL)", + "# Or download from: https://graphviz.org/download/", + ], + }, + }, + "git": { + "name": "Git", + "executables": ["git"], + "install": { + "macos": ["brew install git"], + "debian": ["sudo apt update", "sudo apt install git"], + "wsl": ["sudo apt update", "sudo apt install git"], + "windows": ["# Git: https://git-scm.com/download/win"], + "default": ["# Install Git: https://git-scm.com/download"], + }, + }, + "terraform": { + "name": "Terraform", + "executables": [], + "install": { + "macos": [ + "brew tap hashicorp/tap", + "brew install hashicorp/terraform", + ], + "default": [ + "# Install Terraform: https://developer.hashicorp.com/terraform/install" + ], + }, + }, +} + + def check_dependencies() -> None: - """Check if required command-line tools are available.""" - dependencies = ["dot", "gvpr", "git", get_tf_binary()] - bundle_dir = Path(__file__).parent + """Check if required command-line tools are available. + + Reports all missing dependencies together with OS-specific + installation instructions and a link to the documentation. + """ import sys + bundle_dir = Path(__file__).parent sys.path.append(str(bundle_dir)) - for exe in dependencies: - location = shutil.which(exe) or os.path.isfile(exe) - if location: - click.echo(f" {exe} command detected: {location}") - else: - click.echo( - click.style( - f"\n ERROR: {exe} command executable not detected in path. Please ensure you have installed all required dependencies first", - fg="red", - bold=True, - ) + + missing: List[Tuple[Dict[str, Any], List[str]]] = [] + for key, info in DEPENDENCIES.items(): + executables = info["executables"] or [get_tf_binary()] + not_found = [] + for exe in executables: + location = shutil.which(exe) or os.path.isfile(exe) + if location: + click.echo(f" {exe} command detected: {location}") + else: + not_found.append(exe) + if not_found: + missing.append((info, not_found)) + + if not missing: + return + + os_family = _get_os_family() + + click.echo("\n") + for info, not_found in missing: + exes_str = ", ".join(f"'{e}'" for e in not_found) + click.echo( + click.style(f" ERROR: {exes_str} not found in PATH.", fg="red", bold=True) + ) + click.echo( + click.style( + f" {info['name']} is required but not installed.", fg="red" ) - exit() + ) + + click.echo("\n Install the missing dependencies:\n") + for info, _ in missing: + commands = info["install"].get(os_family, info["install"]["default"]) + for line in commands: + click.echo(f" {line}") + + click.echo("\n For detailed installation instructions:") + click.echo(" https://patrickchugh.github.io/terravision/installation/") + click.echo(" https://github.com/patrickchugh/terravision#install\n") + + exit(1) def check_terraform_version() -> None: diff --git a/tests/test_dependencies.py b/tests/test_dependencies.py new file mode 100644 index 0000000..c64a572 --- /dev/null +++ b/tests/test_dependencies.py @@ -0,0 +1,115 @@ +"""Tests for dependency preflight checks in helpers module.""" + +import pytest +from unittest.mock import patch + +from modules.helpers import check_dependencies, _get_os_family + + +class TestCheckDependencies: + """Tests for check_dependencies() function.""" + + @patch("modules.helpers.shutil.which") + @patch("modules.helpers.os.path.isfile") + def test_all_dependencies_present(self, mock_isfile, mock_which): + """When all dependencies are present, function should echo and return.""" + mock_which.return_value = "/usr/bin/fake" + mock_isfile.return_value = False + + check_dependencies() + + @patch("modules.helpers.shutil.which") + @patch("modules.helpers.os.path.isfile") + def test_missing_single_dependency(self, mock_isfile, mock_which): + """Missing a single dependency should report it and exit.""" + + def fake_which(exe): + return None if exe == "dot" else "/usr/bin/fake" + + mock_which.side_effect = fake_which + mock_isfile.return_value = False + + with pytest.raises(SystemExit) as exc_info: + check_dependencies() + + assert exc_info.value.code == 1 + + @patch("modules.helpers.shutil.which") + @patch("modules.helpers.os.path.isfile") + def test_missing_multiple_dependencies(self, mock_isfile, mock_which): + """Missing multiple dependencies should report all of them.""" + + def fake_which(exe): + return None if exe in ("dot", "terraform") else "/usr/bin/fake" + + mock_which.side_effect = fake_which + mock_isfile.return_value = False + + with pytest.raises(SystemExit) as exc_info: + check_dependencies() + + assert exc_info.value.code == 1 + + @patch("modules.helpers.shutil.which") + @patch("modules.helpers.os.path.isfile") + def test_missing_git_dependency(self, mock_isfile, mock_which): + """Missing git should report Git specifically.""" + + def fake_which(exe): + return None if exe == "git" else "/usr/bin/fake" + + mock_which.side_effect = fake_which + mock_isfile.return_value = False + + with pytest.raises(SystemExit) as exc_info: + check_dependencies() + + assert exc_info.value.code == 1 + + +class TestGetOsFamily: + """Tests for _get_os_family() helper.""" + + @patch("modules.helpers.platform.system") + def test_detects_macos(self, mock_system): + mock_system.return_value = "Darwin" + assert _get_os_family() == "macos" + + @patch("modules.helpers.platform.system") + @patch("modules.helpers.is_wsl") + def test_detects_wsl(self, mock_is_wsl, mock_system): + mock_system.return_value = "Linux" + mock_is_wsl.return_value = True + assert _get_os_family() == "wsl" + + @patch("modules.helpers.platform.system") + @patch("modules.helpers.is_wsl") + @patch("builtins.open") + def test_detects_debian(self, mock_open, mock_is_wsl, mock_system): + mock_system.return_value = "Linux" + mock_is_wsl.return_value = False + mock_open.return_value.__enter__.return_value.read.return_value = ( + 'ID="ubuntu"\nID_LIKE="debian"\n' + ) + assert _get_os_family() == "debian" + + @patch("modules.helpers.platform.system") + @patch("modules.helpers.is_wsl") + @patch("builtins.open") + def test_detects_generic_linux(self, mock_open, mock_is_wsl, mock_system): + mock_system.return_value = "Linux" + mock_is_wsl.return_value = False + mock_open.return_value.__enter__.return_value.read.return_value = ( + 'ID="fedora"\n' + ) + assert _get_os_family() == "linux" + + @patch("modules.helpers.platform.system") + def test_detects_windows(self, mock_system): + mock_system.return_value = "Windows" + assert _get_os_family() == "windows" + + @patch("modules.helpers.platform.system") + def test_detects_unknown(self, mock_system): + mock_system.return_value = "FreeBSD" + assert _get_os_family() == "unknown"