Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
133 changes: 117 additions & 16 deletions modules/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

import json
import os
import platform
import re
import shutil
import subprocess
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
115 changes: 115 additions & 0 deletions tests/test_dependencies.py
Original file line number Diff line number Diff line change
@@ -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
Comment on lines +32 to +35

@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"
Loading