Skip to content
Closed
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
14 changes: 14 additions & 0 deletions .github/linters/.ruff.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# Ruff configuration for test fixtures
# This configuration relaxes rules for test fixtures which are
# often using non-standard import orders and mock functionality

extend = "../../pyproject.toml"

[lint]
ignore = [
"E402", # Module level import not at top of file (common in test fixtures)
"F401", # Unused import (needed for test fixtures)
"F811", # Redefinition of unused name (common in test fixtures)
"F821", # Undefined name (often happens with dynamically created objects)
"F841", # Local variable assigned but never used (common in test fixtures)
]
11 changes: 11 additions & 0 deletions .github/workflows/pr-checks.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,21 @@ jobs:

# Install development dependencies
uv pip install -e ".[dev]"

# Install additional type stubs for CI
uv pip install types-PyYAML

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

- name: Install sbctl
run: |
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/
sbctl --version || echo "sbctl installed but version command not available"

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

Expand Down
10 changes: 10 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",
]

[tool.setuptools.packages.find]
Expand Down Expand Up @@ -74,6 +75,15 @@ timeout = 30
[tool.ruff]
line-length = 100
target-version = "py313"
exclude = [
".git",
".github",
"__pycache__",
"build",
"dist",
"*.yml",
"*.yaml",
]

[tool.black]
line-length = 100
Expand Down
14 changes: 10 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,12 +134,17 @@ 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 need to configure it properly
if mcp_mode:
logger.debug("Configuring MCP server for stdio mode")
mcp.use_stdio = True
# FastMCP might not have use_stdio attribute directly - use constructor args
# or appropriate method instead
# Set up signal handlers specifically for stdio mode
setup_signal_handlers()

# Run the FastMCP server in stdio mode
# This is a workaround until we can properly inspect the FastMCP class
os.environ["MCP_USE_STDIO"] = "1"

# Register shutdown function with atexit to ensure cleanup on normal exit
logger.debug("Registering atexit shutdown handler")
Expand Down
37 changes: 29 additions & 8 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 Any, List, Optional, Tuple, Dict, Union
from urllib.parse import urlparse

import aiohttp
Expand All @@ -26,6 +26,27 @@
# Set up logging
logger = logging.getLogger(__name__)


def safe_copy_file(src_path: Optional[Path], dst_path: Optional[Path]) -> bool:
"""
Safely copy a file, handling None paths by converting to strings.

Args:
src_path: Source path, may be None
dst_path: Destination path, may be None

Returns:
True if the copy was successful, False otherwise
"""
if not src_path or not dst_path:
return False

try:
shutil.copy2(str(src_path), str(dst_path))
return True
except Exception:
return False

# Constants for resource limits - can be overridden by environment variables
DEFAULT_DOWNLOAD_SIZE = 1024 * 1024 * 1024 # 1 GB
DEFAULT_DOWNLOAD_TIMEOUT = 300 # 5 minutes
Expand Down Expand Up @@ -829,7 +850,7 @@ async def _wait_for_initialization(
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 @@ -856,7 +877,7 @@ async def _wait_for_initialization(
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 Down Expand Up @@ -1625,7 +1646,7 @@ async def _get_system_info(self) -> dict:
else:
info[f"port_{port}_listening"] = False
else:
info["netstat_error"] = stderr.decode()
info["netstat_error"] = stderr.decode() if stderr else ""
except Exception as e:
info["netstat_error"] = str(e)

Expand All @@ -1646,9 +1667,9 @@ async def _get_system_info(self) -> dict:
stdout, stderr = await proc.communicate()

if proc.returncode == 0:
info[f"curl_{port}_status_code"] = stdout.decode().strip()
info[f"curl_{port}_status_code"] = int(stdout.decode().strip())
else:
info[f"curl_{port}_error"] = stderr.decode()
info[f"curl_{port}_error"] = stderr.decode() if stderr else ""
except Exception as e:
info[f"curl_{port}_error"] = str(e)

Expand All @@ -1669,15 +1690,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[Dict[str, Any]] = []

# 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
13 changes: 8 additions & 5 deletions src/mcp_server_troubleshoot/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from pathlib import Path
import argparse
import os
from typing import Optional, List

from .server import mcp, shutdown
from .config import get_recommended_client_config
Expand Down Expand Up @@ -52,10 +53,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 parse_args():
def parse_args() -> argparse.Namespace:
"""Parse command-line arguments for the MCP server."""
parser = argparse.ArgumentParser(description="MCP server for Kubernetes support bundles")
parser.add_argument("--bundle-dir", type=Path, help="Directory to store bundles")
Expand Down Expand Up @@ -85,14 +87,14 @@ def parse_args():
return parser.parse_args()


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)
sys.exit(0)


def main():
def main() -> None:
"""
Main entry point that adapts based on how it's called.
This allows the module to be used both as a direct CLI and
Expand Down Expand Up @@ -138,7 +140,8 @@ def main():
# Configure the MCP server for stdio mode if needed
if mcp_mode:
logger.debug("Configuring MCP server for stdio mode")
mcp.use_stdio = True
# FastMCP might not have use_stdio attribute directly - use environment variable
os.environ["MCP_USE_STDIO"] = "1"
# Set up signal handlers specifically for stdio mode
setup_signal_handlers()

Expand Down
3 changes: 2 additions & 1 deletion src/mcp_server_troubleshoot/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,8 @@ def load_config_from_path(config_path: str) -> Dict[str, Any]:
raise FileNotFoundError(f"Configuration file not found: {path}")

with open(path, "r") as f:
return json.load(f)
result: Dict[str, Any] = json.load(f)
return result


def load_config_from_env() -> Optional[Dict[str, Any]]:
Expand Down
15 changes: 11 additions & 4 deletions src/mcp_server_troubleshoot/kubectl.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,18 +23,18 @@
class KubectlError(Exception):
"""Exception raised when a kubectl command fails."""

def __init__(self, message: str, exit_code: int, stderr: str) -> None:
def __init__(self, message: str, exit_code: int | None, stderr: str) -> None:
"""
Initialize a KubectlError exception.

Args:
message: The error message
exit_code: The command exit code
exit_code: The command exit code, may be None
stderr: The standard error output
"""
self.exit_code = exit_code
self.exit_code = exit_code if exit_code is not None else 1
self.stderr = stderr
super().__init__(f"{message} (exit code {exit_code}): {stderr}")
super().__init__(f"{message} (exit code {self.exit_code}): {stderr}")


class KubectlCommandArgs(BaseModel):
Expand Down Expand Up @@ -95,6 +95,13 @@ class KubectlResult(BaseModel):
output: Any = Field(description="The parsed output, if applicable")
is_json: bool = Field(description="Whether the output is JSON")
duration_ms: int = Field(description="The duration of the command execution in milliseconds")

@field_validator("exit_code")
def validate_exit_code(cls, v: int | None) -> int:
"""Validate that exit_code is an integer and not None."""
if v is None:
return 1 # Default exit code for errors
return v


class KubectlExecutor:
Expand Down
2 changes: 1 addition & 1 deletion src/mcp_server_troubleshoot/lifecycle.py
Original file line number Diff line number Diff line change
Expand Up @@ -205,7 +205,7 @@ def handle_signal(signum: int, frame: Any) -> None:
import shutil

shutil.rmtree(bundle_path)
logger.info(f"Successfully removed bundle directory")
logger.info("Successfully removed bundle directory")
except Exception as e:
logger.error(f"Error removing bundle directory: {e}")
except Exception as e:
Expand Down
13 changes: 9 additions & 4 deletions src/mcp_server_troubleshoot/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
import signal
import sys
from pathlib import Path
from typing import List, Optional
from typing import List, Optional, Callable

from mcp.server.fastmcp import FastMCP
from mcp.types import TextContent
Expand All @@ -26,6 +26,11 @@

logger = logging.getLogger(__name__)

# Initialize global variables for singleton pattern
_bundle_manager: Optional[BundleManager] = None
_kubectl_executor: Optional[KubectlExecutor] = None
_file_explorer: Optional[FileExplorer] = None

# Create FastMCP server with lifecycle management
# We don't enable stdio mode here - it will be configured in __main__.py
mcp = FastMCP("troubleshoot-mcp-server", lifespan=app_lifespan)
Expand Down Expand Up @@ -566,7 +571,7 @@ async def grep_files(args: GrepFilesArgs) -> List[TextContent]:
# If we have matches, show them
if result.matches:
# Group matches by file
matches_by_file = {}
matches_by_file: dict[str, list] = {}
for match in result.matches:
if match.path not in matches_by_file:
matches_by_file[match.path] = []
Expand Down Expand Up @@ -697,10 +702,10 @@ def register_signal_handlers() -> None:
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)

def signal_handler(sig_name: str):
def signal_handler(sig_name: str) -> Callable[[], None]:
"""Create a signal handler that triggers cleanup."""

def handler():
def handler() -> None:
logger.info(f"Received {sig_name}, initiating graceful shutdown")
if not loop.is_closed():
# Schedule the cleanup task
Expand Down
5 changes: 5 additions & 0 deletions tests/.ruff.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Ruff configuration for tests directory
# This applies more relaxed rules for test files which often use
# non-standard import orders, mocks, and test fixtures

extend = "../.github/linters/.ruff.toml"
Loading
Loading