Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
bc42712
Fix type checking errors
chris-sanders May 5, 2025
c81ec43
Fix strict typing issues
chris-sanders May 6, 2025
824bd63
Fix additional typing issues in __main__.py and cli.py
chris-sanders May 6, 2025
d34e774
Fix integration tests to use mock sbctl when needed
chris-sanders May 6, 2025
69ff2aa
Fix integration tests to use mock sbctl when needed
chris-sanders May 6, 2025
f2ee300
Restore original test approach assuming sbctl is available
chris-sanders May 6, 2025
f45cad3
Add sbctl installation to GitHub workflows
chris-sanders May 6, 2025
c0ec8b5
Fix linting and formatting issues
chris-sanders May 6, 2025
acb974c
Fix integration test fixture dependency
chris-sanders May 6, 2025
ab0c567
Fix linting and import order issues in test files
chris-sanders May 6, 2025
44bd62e
Add types-PyYAML to dev dependencies
chris-sanders May 6, 2025
cdfd140
Fix E2E tests with container build checks and non-container tests
chris-sanders May 6, 2025
692ec4e
Fix formatting and linting in e2e test files
chris-sanders May 6, 2025
96adeea
Fix sbctl availability in integration tests
chris-sanders May 6, 2025
0e238e6
Revert "Fix sbctl availability in integration tests"
chris-sanders May 6, 2025
252e63e
Add debugging for sbctl installation in CI
chris-sanders May 6, 2025
61b4045
Re-order CI workflow to run fastest checks first
chris-sanders May 6, 2025
e63eb0b
Avoid running tests twice in CI workflow
chris-sanders May 6, 2025
d1d0df3
Fix non-container E2E tests
chris-sanders May 6, 2025
637526b
Apply black formatting
chris-sanders May 6, 2025
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
46 changes: 39 additions & 7 deletions .github/workflows/pr-checks.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -36,12 +36,6 @@ jobs:
# Install development dependencies
uv pip install -e ".[dev]"

- name: Run unit tests
run: uv run pytest -m unit -v

- name: Run integration tests
run: uv run pytest -m integration -v

- name: Run linting
run: uv run ruff check .

Expand All @@ -50,9 +44,27 @@ jobs:

- name: Run type checking
run: uv run mypy src

- name: Install sbctl
run: |
# Install sbctl binary for integration tests
# Several tests verify direct sbctl functionality and should not be skipped
# This ensures tests like test_real_bundle.py's test_sbctl_help_behavior pass
mkdir -p /tmp/sbctl && cd /tmp/sbctl
curl -L -o sbctl.tar.gz "https://github.com/replicatedhq/sbctl/releases/latest/download/sbctl_linux_amd64.tar.gz"
tar xzf sbctl.tar.gz
chmod +x sbctl
sudo mv sbctl /usr/local/bin/
cd / && rm -rf /tmp/sbctl

# Debug sbctl installation
which sbctl
ls -la /usr/local/bin/sbctl
echo "PATH: $PATH"
sbctl --help

- name: Run all tests with coverage
run: uv run pytest --cov=src --cov-report=xml
run: uv run pytest --cov=src --cov-report=xml -v

- name: Upload coverage report
uses: codecov/codecov-action@v3
Expand Down Expand Up @@ -88,6 +100,16 @@ jobs:
# Install development dependencies
uv pip install -e ".[dev]"

- name: Install sbctl
run: |
mkdir -p /tmp/sbctl && cd /tmp/sbctl
curl -L -o sbctl.tar.gz "https://github.com/replicatedhq/sbctl/releases/latest/download/sbctl_linux_amd64.tar.gz"
tar xzf sbctl.tar.gz
chmod +x sbctl
sudo mv sbctl /usr/local/bin/
cd / && rm -rf /tmp/sbctl
sbctl --help

- name: Check Podman version
run: podman --version

Expand Down Expand Up @@ -142,5 +164,15 @@ jobs:
# Install development dependencies
uv pip install -e ".[dev]"

- name: Install sbctl
run: |
mkdir -p /tmp/sbctl && cd /tmp/sbctl
curl -L -o sbctl.tar.gz "https://github.com/replicatedhq/sbctl/releases/latest/download/sbctl_linux_amd64.tar.gz"
tar xzf sbctl.tar.gz
chmod +x sbctl
sudo mv sbctl /usr/local/bin/
cd / && rm -rf /tmp/sbctl
sbctl --help

- name: Run E2E tests (excluding container tests)
run: uv run pytest -m "e2e and not container" -v
6 changes: 6 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ dev = [
"black",
"ruff",
"mypy",
"types-PyYAML", # Type stubs for PyYAML
]

[tool.setuptools.packages.find]
Expand Down Expand Up @@ -74,6 +75,11 @@ timeout = 30
[tool.ruff]
line-length = 100
target-version = "py313"
# Exclude specific modules or use per-file rules as needed
exclude = [
"tests/fixtures/mock_sbctl.py",
"tests/fixtures/mock_kubectl.py"
]

[tool.black]
line-length = 100
Expand Down
9 changes: 5 additions & 4 deletions src/mcp_server_troubleshoot/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,10 +54,11 @@ def setup_logging(verbose: bool = False, mcp_mode: bool = False) -> None:
# Configure root logger to use stderr
root_logger = logging.getLogger()
for handler in root_logger.handlers:
handler.stream = sys.stderr
if hasattr(handler, "stream"):
handler.stream = sys.stderr


def handle_show_config():
def handle_show_config() -> None:
"""Output recommended client configuration."""
config = get_recommended_client_config()
json.dump(config, sys.stdout, indent=2)
Expand Down Expand Up @@ -133,10 +134,10 @@ def main(args: Optional[List[str]] = None) -> None:
logger.debug(f"Using bundle directory: {bundle_dir}")

# Configure the MCP server based on the mode
# In stdio mode, we enable use_stdio=True
# In stdio mode, we use environment variable to control behavior
if mcp_mode:
logger.debug("Configuring MCP server for stdio mode")
mcp.use_stdio = True
os.environ["MCP_USE_STDIO"] = "true"
# Set up signal handlers specifically for stdio mode
setup_signal_handlers()

Expand Down
131 changes: 89 additions & 42 deletions src/mcp_server_troubleshoot/bundle.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
import tarfile
import tempfile
from pathlib import Path
from typing import List, Optional, Tuple
from typing import List, Optional, Tuple, Union
from urllib.parse import urlparse

import aiohttp
Expand Down Expand Up @@ -48,6 +48,28 @@
"SBCTL_ALLOW_ALTERNATIVE_KUBECONFIG", "true"
).lower() in ("true", "1", "yes")


# Helper function for safe file copying
def safe_copy_file(src: Union[Path, None], dst: Union[Path, None]) -> None:
"""
Safely copy a file, handling Path | None types.

Args:
src: Source path (may be None)
dst: Destination path (may be None)

Raises:
ValueError: If src or dst is None
"""
if src is None:
raise ValueError("Source path cannot be None")
if dst is None:
raise ValueError("Destination path cannot be None")

# Both paths are valid, perform the copy
shutil.copy2(src, dst)


logger.debug(f"Using MAX_DOWNLOAD_SIZE: {MAX_DOWNLOAD_SIZE / 1024 / 1024:.1f} MB")
logger.debug(f"Using MAX_DOWNLOAD_TIMEOUT: {MAX_DOWNLOAD_TIMEOUT} seconds")
logger.debug(f"Using MAX_INITIALIZATION_TIMEOUT: {MAX_INITIALIZATION_TIMEOUT} seconds")
Expand Down Expand Up @@ -453,7 +475,8 @@ async def _get_replicated_signed_url(self, original_url: str) -> str:
raise BundleDownloadError("Could not find 'signedUri' in Replicated API response.")

logger.info("Successfully retrieved signed URL from Replicated API.")
return signed_url
# Ensure we're returning a string type
return str(signed_url)
# === END RESTRUCTURE ===

except Exception as e:
Expand Down Expand Up @@ -729,20 +752,40 @@ async def _wait_for_initialization(

# Attempt to read process output for diagnostic purposes
if self.sbctl_process and self.sbctl_process.stdout and self.sbctl_process.stderr:
stdout_data = ""
stderr_data = ""
stdout_data = b""
stderr_data = b""

try:
# Try to read without blocking the entire process
stdout_data = await asyncio.wait_for(
self.sbctl_process.stdout.read(1024), timeout=1.0
)
stderr_data = await asyncio.wait_for(
self.sbctl_process.stderr.read(1024), timeout=1.0
)
# We need to handle the coroutines properly for type checking
if self.sbctl_process.stdout is not None:
try:
# We expect bytes returned from the process stdout
stdout_data = await asyncio.wait_for(
self.sbctl_process.stdout.read(1024), timeout=1.0
)
except (asyncio.TimeoutError, Exception):
stdout_data = b""
else:
stdout_data = b""

if self.sbctl_process.stderr is not None:
try:
# We expect bytes returned from the process stderr
stderr_data = await asyncio.wait_for(
self.sbctl_process.stderr.read(1024), timeout=1.0
)
except (asyncio.TimeoutError, Exception):
stderr_data = b""
else:
stderr_data = b""

if stdout_data:
stdout_text = stdout_data.decode("utf-8", errors="replace")
stdout_text = (
stdout_data.decode("utf-8", errors="replace")
if isinstance(stdout_data, bytes)
else str(stdout_data)
)
logger.debug(f"sbctl stdout: {stdout_text}")

# Look for exported KUBECONFIG path in the output
Expand All @@ -759,8 +802,13 @@ async def _wait_for_initialization(
alternative_kubeconfig_paths.append(alt_kubeconfig)

if stderr_data:
logger.debug(f"sbctl stderr: {stderr_data.decode('utf-8', errors='replace')}")
error_message = stderr_data.decode("utf-8", errors="replace")
stderr_text = (
stderr_data.decode("utf-8", errors="replace")
if isinstance(stderr_data, bytes)
else str(stderr_data)
)
logger.debug(f"sbctl stderr: {stderr_text}")
error_message = stderr_text
except (asyncio.TimeoutError, Exception) as e:
logger.debug(f"Error reading process output: {str(e)}")

Expand Down Expand Up @@ -827,9 +875,8 @@ async def _wait_for_initialization(

# Try to copy to expected location
try:
import shutil

shutil.copy2(alt_path, kubeconfig_path)
safe_copy_file(alt_path, kubeconfig_path)
logger.info(
f"Copied kubeconfig from {alt_path} to {kubeconfig_path}"
)
Expand All @@ -854,9 +901,7 @@ async def _wait_for_initialization(
# make sure it's copied to the expected location
if found_kubeconfig_path != kubeconfig_path:
try:
import shutil

shutil.copy2(found_kubeconfig_path, kubeconfig_path)
safe_copy_file(found_kubeconfig_path, kubeconfig_path)
logger.info(
f"Copied kubeconfig from {found_kubeconfig_path} to {kubeconfig_path}"
)
Expand All @@ -879,9 +924,7 @@ async def _wait_for_initialization(
# Make sure we have a kubeconfig at expected location
if found_kubeconfig_path != kubeconfig_path:
try:
import shutil

shutil.copy2(found_kubeconfig_path, kubeconfig_path)
logger.info(
f"Copied kubeconfig from {found_kubeconfig_path} to {kubeconfig_path}"
)
Expand All @@ -891,19 +934,22 @@ async def _wait_for_initialization(
return

# If we've found the kubeconfig and waited long enough, continue anyway
time_since_kubeconfig = asyncio.get_event_loop().time() - kubeconfig_found_time
if time_since_kubeconfig > (timeout * api_server_wait_percentage):
logger.warning(
f"API server not responding after {time_since_kubeconfig:.1f}s "
f"({api_server_wait_percentage*100:.0f}% of timeout). Proceeding anyway."
# Make sure kubeconfig_found_time is not None before subtraction
if kubeconfig_found_time is not None:
time_since_kubeconfig = (
asyncio.get_event_loop().time() - kubeconfig_found_time
)
if time_since_kubeconfig > (timeout * api_server_wait_percentage):
logger.warning(
f"API server not responding after {time_since_kubeconfig:.1f}s "
f"({api_server_wait_percentage*100:.0f}% of timeout). Proceeding anyway."
)

# Make sure we have a kubeconfig at expected location
if found_kubeconfig_path != kubeconfig_path:
try:
import shutil

shutil.copy2(found_kubeconfig_path, kubeconfig_path)
# Use our safe_copy_file helper instead of shutil.copy2 directly
safe_copy_file(found_kubeconfig_path, kubeconfig_path)
logger.info(
f"Copied kubeconfig from {found_kubeconfig_path} to {kubeconfig_path}"
)
Expand Down Expand Up @@ -945,9 +991,8 @@ async def _wait_for_initialization(
# Make sure we have a kubeconfig at expected location
if found_kubeconfig_path != kubeconfig_path:
try:
import shutil

shutil.copy2(found_kubeconfig_path, kubeconfig_path)
# Use our safe_copy_file helper instead of shutil.copy2 directly
safe_copy_file(found_kubeconfig_path, kubeconfig_path)
logger.info(
f"Copied kubeconfig from {found_kubeconfig_path} to {kubeconfig_path}"
)
Expand Down Expand Up @@ -1442,9 +1487,11 @@ async def check_api_server_available(self) -> bool:
url = f"http://{host}:{port}{endpoint}"
logger.debug(f"Checking API server at {url}")

# Create a properly typed timeout object
timeout = aiohttp.ClientTimeout(total=2.0)
async with aiohttp.ClientSession() as session:
try:
async with session.get(url, timeout=2.0) as response:
async with session.get(url, timeout=timeout) as response:
logger.debug(
f"API server endpoint {url} returned status {response.status}"
)
Expand Down Expand Up @@ -1504,7 +1551,7 @@ async def check_api_server_available(self) -> bool:
logger.warning("API server is not available at any endpoint")
return False

async def get_diagnostic_info(self) -> dict:
async def get_diagnostic_info(self) -> dict[str, object]:
"""
Get diagnostic information about the current bundle and sbctl.

Expand Down Expand Up @@ -1567,14 +1614,14 @@ async def _check_sbctl_available(self) -> bool:
logger.warning(f"Error checking sbctl availability: {str(e)}")
return False

async def _get_system_info(self) -> dict:
async def _get_system_info(self) -> dict[str, object]:
"""
Get system information.

Returns:
A dictionary with system information
A dictionary with system information, values can be any type
"""
info = {}
info: dict[str, object] = {}

# Get the API port from environment or default
ports_to_check = [8080] # Default port
Expand Down Expand Up @@ -1625,9 +1672,9 @@ async def _get_system_info(self) -> dict:
else:
info[f"port_{port}_listening"] = False
else:
info["netstat_error"] = stderr.decode()
info["netstat_error_text"] = stderr.decode()
except Exception as e:
info["netstat_error"] = str(e)
info["netstat_exception_text"] = str(e)

# Try curl to test API server on this port
try:
Expand All @@ -1648,9 +1695,9 @@ async def _get_system_info(self) -> dict:
if proc.returncode == 0:
info[f"curl_{port}_status_code"] = stdout.decode().strip()
else:
info[f"curl_{port}_error"] = stderr.decode()
info[f"curl_{port}_error_text"] = stderr.decode()
except Exception as e:
info[f"curl_{port}_error"] = str(e)
info[f"curl_{port}_exception_text"] = str(e)

# Add environment info
info["env_mock_k8s_api_port"] = os.environ.get("MOCK_K8S_API_PORT", "not set")
Expand All @@ -1669,15 +1716,15 @@ async def list_available_bundles(self, include_invalid: bool = False) -> List[Bu
"""
logger.info(f"Listing available bundles in {self.bundle_dir}")

bundles = []
bundles: List[BundleFileInfo] = []

# Check if bundle directory exists
if not self.bundle_dir.exists():
logger.warning(f"Bundle directory {self.bundle_dir} does not exist")
return bundles

# Find files with bundle extensions
bundle_files = []
bundle_files: List[Path] = []
bundle_extensions = [".tar.gz", ".tgz"]

for ext in bundle_extensions:
Expand Down
Loading