Skip to main content
Glama
test_service_installer.py35.4 kB
"""Tests for the service installer module.""" import json import tempfile from pathlib import Path from unittest.mock import patch import pytest from src.homelab_mcp.service_installer import ServiceInstaller class TestServiceInstaller: """Test ServiceInstaller class.""" def setup_method(self): """Set up test method.""" # Create a temporary directory for templates self.temp_dir = tempfile.mkdtemp() self.template_dir = Path(self.temp_dir) / "service_templates" self.template_dir.mkdir() # Create a sample service template self.sample_template = { "name": "test-service", "description": "Test service for unit tests", "category": "test", "requirements": {"ports": [8080], "memory_gb": 1, "disk_gb": 5}, "installation": { "method": "docker-compose", "compose": { "version": "3.8", "services": { "test": {"image": "nginx:latest", "ports": ["8080:80"]} }, }, }, } template_file = self.template_dir / "test-service.yaml" with open(template_file, "w") as f: import yaml yaml.dump(self.sample_template, f) # Patch the templates directory self.patcher = patch( "src.homelab_mcp.service_installer.TEMPLATES_DIR", self.template_dir ) self.patcher.start() self.installer = ServiceInstaller() def teardown_method(self): """Tear down test method.""" self.patcher.stop() import shutil shutil.rmtree(self.temp_dir) def test_load_service_templates(self): """Test loading service templates from directory.""" templates = self.installer.templates assert "test-service" in templates assert templates["test-service"]["name"] == "test-service" assert templates["test-service"]["category"] == "test" def test_get_available_services(self): """Test getting list of available services.""" services = self.installer.get_available_services() assert isinstance(services, list) assert "test-service" in services def test_get_service_info(self): """Test getting service information.""" info = self.installer.get_service_info("test-service") assert info is not None assert info["name"] == "test-service" assert info["description"] == "Test service for unit tests" assert "requirements" in info assert "installation" in info def test_get_service_info_nonexistent(self): """Test getting info for nonexistent service.""" info = self.installer.get_service_info("nonexistent-service") assert info is None @pytest.mark.asyncio async def test_check_service_requirements_success(self): """Test checking service requirements when all are met.""" # Mock SSH execution for requirement checks with patch("src.homelab_mcp.service_installer.ssh_execute_command") as mock_ssh: # Mock port check (port available) mock_ssh.return_value = json.dumps( { "status": "success", "exit_code": 1, # Port not in use (good) "output": "", } ) # Mock memory check def memory_check(*args, **kwargs): if "free -m" in kwargs.get("command", ""): return json.dumps( { "status": "success", "exit_code": 0, "output": "Output:\n2048", # 2GB available } ) return mock_ssh.return_value mock_ssh.side_effect = memory_check result = await self.installer.check_service_requirements( "test-service", "test-host", "test-user", "test-pass" ) assert result["service"] == "test-service" assert result["hostname"] == "test-host" assert result["requirements_met"] is True assert "checks" in result @pytest.mark.asyncio async def test_check_service_requirements_port_conflict(self): """Test checking requirements when port is in use.""" with patch("src.homelab_mcp.service_installer.ssh_execute_command") as mock_ssh: # Handle different commands appropriately def command_handler(*args, **kwargs): command = kwargs.get("command", "") if "ss -tlnp" in command: # Port check return json.dumps( { "status": "success", "exit_code": 0, # Port in use (bad) "output": "tcp 0.0.0.0:8080 LISTEN", } ) elif "free -m" in command: # Memory check return json.dumps( { "status": "success", "exit_code": 0, "output": "Output:\n2048", # 2GB available } ) elif "df /" in command: # Disk check return json.dumps( { "status": "success", "exit_code": 0, "output": "Output:\n20480000", # 20GB available (in KB) } ) else: return json.dumps( {"status": "success", "exit_code": 0, "output": ""} ) mock_ssh.side_effect = command_handler result = await self.installer.check_service_requirements( "test-service", "test-host", "test-user", "test-pass" ) assert result["requirements_met"] is False assert "port_8080" in result["checks"] assert result["checks"]["port_8080"]["status"] == "fail" @pytest.mark.asyncio async def test_check_service_requirements_unknown_service(self): """Test checking requirements for unknown service.""" result = await self.installer.check_service_requirements( "unknown-service", "test-host", "test-user", "test-pass" ) assert result["status"] == "error" assert "Unknown service" in result["error"] @pytest.mark.asyncio async def test_install_service_docker_compose(self): """Test installing service with Docker Compose method.""" # Mock successful requirement check with patch.object(self.installer, "check_service_requirements") as mock_check: mock_check.return_value = {"requirements_met": True, "checks": {}} # Mock Docker Compose installation with patch.object( self.installer, "_install_docker_compose_service" ) as mock_install: mock_install.return_value = { "status": "success", "service": "test-service", "method": "docker-compose", } result = await self.installer.install_service( "test-service", "test-host", "test-user", "test-pass" ) assert result["status"] == "success" assert result["service"] == "test-service" assert result["method"] == "docker-compose" # Verify requirement check was called mock_check.assert_called_once_with( "test-service", "test-host", "test-user", "test-pass" ) @pytest.mark.asyncio async def test_install_service_requirements_not_met(self): """Test installing service when requirements are not met.""" with patch.object(self.installer, "check_service_requirements") as mock_check: mock_check.return_value = { "requirements_met": False, "checks": {"port_8080": {"status": "fail"}}, } result = await self.installer.install_service( "test-service", "test-host", "test-user", "test-pass" ) assert result["status"] == "error" assert "Requirements not met" in result["error"] assert "requirement_check" in result @pytest.mark.asyncio async def test_install_service_unknown_service(self): """Test installing unknown service.""" result = await self.installer.install_service( "unknown-service", "test-host", "test-user", "test-pass" ) assert result["status"] == "error" assert "Unknown service" in result["error"] @pytest.mark.asyncio async def test_install_docker_compose_service(self): """Test Docker Compose installation method.""" service_config = self.sample_template.copy() with patch("src.homelab_mcp.service_installer.ssh_execute_command") as mock_ssh: # Mock successful command execution mock_ssh.return_value = json.dumps( {"status": "success", "exit_code": 0, "output": "Success"} ) result = await self.installer._install_docker_compose_service( "test-service", service_config, "test-host", "test-user", "test-pass", {}, ) assert "status" in result # The actual implementation would have more specific assertions def test_load_service_templates_with_invalid_yaml(self): """Test loading templates with invalid YAML files.""" # Create invalid YAML file invalid_file = self.template_dir / "invalid.yaml" with open(invalid_file, "w") as f: f.write("invalid: yaml: content: [unclosed") # Reload installer installer = ServiceInstaller() # Should load valid templates and skip invalid ones assert "test-service" in installer.templates assert "invalid" not in installer.templates def test_get_service_info_with_complex_template(self): """Test service info with complex template structure.""" # Create more complex template complex_template = { "name": "complex-service", "description": "Complex service with multiple features", "category": "infrastructure", "requirements": { "ports": [80, 443, 8080], "memory_gb": 4, "disk_gb": 20, "cpu_cores": 2, }, "installation": { "method": "terraform", "terraform": {"version": ">=1.0", "providers": {"docker": ">=2.0"}}, }, "configuration": { "env_vars": {"DB_HOST": "localhost", "DB_PORT": "5432"}, "volumes": ["/data:/app/data", "/logs:/app/logs"], }, } template_file = self.template_dir / "complex-service.yaml" with open(template_file, "w") as f: import yaml yaml.dump(complex_template, f) # Reload installer installer = ServiceInstaller() info = installer.get_service_info("complex-service") assert info is not None assert info["name"] == "complex-service" assert info["category"] == "infrastructure" assert len(info["requirements"]["ports"]) == 3 assert info["installation"]["method"] == "terraform" assert "configuration" in info class TestServiceInstallerIntegration: """Integration tests for ServiceInstaller with real YAML files.""" @pytest.mark.asyncio async def test_integration_with_mock_ssh(self): """Test service installer with mocked SSH operations.""" installer = ServiceInstaller() # Skip if no templates available (in case templates directory is empty) if not installer.get_available_services(): pytest.skip("No service templates available") service_name = installer.get_available_services()[0] with patch("src.homelab_mcp.service_installer.ssh_execute_command") as mock_ssh: # Mock all SSH commands to return success mock_ssh.return_value = json.dumps( { "status": "success", "exit_code": 1, # Assume ports are available "output": "Output:\n8192", # Assume sufficient resources } ) # Check requirements result = await installer.check_service_requirements( service_name, "test-host", "test-user", "test-pass" ) assert "requirements_met" in result assert "checks" in result def test_template_validation(self): """Test that all loaded templates have required fields.""" installer = ServiceInstaller() for service_name, template in installer.templates.items(): # Every template should have basic required fields assert "name" in template or service_name assert "installation" in template assert "method" in template["installation"] # Method should be one of supported types supported_methods = [ "docker-compose", "terraform", "ansible", "script", "iso_installation", ] assert template["installation"]["method"] in supported_methods # If requirements exist, they should be properly structured if "requirements" in template: req = template["requirements"] if "ports" in req: assert isinstance(req["ports"], list) if "memory_gb" in req: assert isinstance(req["memory_gb"], int | float) if "disk_gb" in req: assert isinstance(req["disk_gb"], int | float) class TestServiceInstallerAnsibleMethod: """Test Ansible installation method.""" def setup_method(self): """Set up test method.""" # Create a temporary directory for templates self.temp_dir = tempfile.mkdtemp() self.template_dir = Path(self.temp_dir) / "service_templates" self.template_dir.mkdir() # Create a sample Ansible service template self.ansible_template = { "name": "ansible-service", "description": "Test service using Ansible", "category": "test", "requirements": {"ports": [8080], "memory_gb": 2, "disk_gb": 10}, "installation": { "method": "ansible", "ansible": { "pre_tasks": [ { "name": "Install Docker", "shell": "curl -fsSL https://get.docker.com | sh", } ], "tasks": [ { "name": "Deploy service", "docker_container": { "name": "test-container", "image": "nginx:latest", "state": "started", "ports": ["8080:80"], }, } ], "post_tasks": [ { "name": "Verify service", "uri": {"url": "http://localhost:8080", "method": "GET"}, } ], "handlers": [ { "name": "restart service", "docker_container": { "name": "test-container", "state": "restarted", }, } ], }, }, } template_file = self.template_dir / "ansible-service.yaml" with open(template_file, "w") as f: import yaml yaml.dump(self.ansible_template, f) # Patch the templates directory self.patcher = patch( "src.homelab_mcp.service_installer.TEMPLATES_DIR", self.template_dir ) self.patcher.start() self.installer = ServiceInstaller() def teardown_method(self): """Tear down test method.""" self.patcher.stop() import shutil shutil.rmtree(self.temp_dir) @pytest.mark.asyncio async def test_install_ansible_service_success(self): """Test successful Ansible service installation.""" # Mock successful requirement check with patch.object(self.installer, "check_service_requirements") as mock_check: mock_check.return_value = {"requirements_met": True, "checks": {}} # Mock Ansible installation with patch.object( self.installer, "_install_ansible_service" ) as mock_install: mock_install.return_value = { "status": "success", "service": "ansible-service", "method": "ansible", "playbook_result": { "pre_tasks": 1, "tasks": 1, "post_tasks": 1, "handlers": 0, }, } result = await self.installer.install_service( "ansible-service", "test-host", "test-user", "test-pass" ) assert result["status"] == "success" assert result["method"] == "ansible" assert "playbook_result" in result @pytest.mark.asyncio async def test_install_ansible_service_with_variables(self): """Test Ansible service installation with variable substitution.""" variables = { "service_port": 9080, "service_name": "custom-nginx", "enable_ssl": True, } with patch.object(self.installer, "check_service_requirements") as mock_check: mock_check.return_value = {"requirements_met": True} with patch.object( self.installer, "_install_ansible_service" ) as mock_install: mock_install.return_value = { "status": "success", "variables_applied": variables, } await self.installer.install_service( "ansible-service", "test-host", "test-user", "test-pass", variables ) # Verify variables were passed to ansible installer mock_install.assert_called_once() call_args = mock_install.call_args assert call_args[0][5] == variables # variables parameter @pytest.mark.asyncio async def test_install_ansible_service_playbook_failure(self): """Test Ansible service installation with playbook failure.""" with patch.object(self.installer, "check_service_requirements") as mock_check: mock_check.return_value = {"requirements_met": True} with patch.object( self.installer, "_install_ansible_service" ) as mock_install: mock_install.return_value = { "status": "error", "error": "Ansible playbook failed at task 'Deploy service'", "failed_task": "Deploy service", "playbook_result": { "pre_tasks": 1, "tasks": 0, # Failed here "post_tasks": 0, "handlers": 0, }, } result = await self.installer.install_service( "ansible-service", "test-host", "test-user", "test-pass" ) assert result["status"] == "error" assert "playbook failed" in result["error"].lower() assert "failed_task" in result def test_ansible_template_validation(self): """Test Ansible template structure validation.""" info = self.installer.get_service_info("ansible-service") assert info is not None assert info["installation"]["method"] == "ansible" ansible_config = info["installation"]["ansible"] assert "tasks" in ansible_config assert "pre_tasks" in ansible_config assert "post_tasks" in ansible_config assert "handlers" in ansible_config # Verify task structure assert len(ansible_config["tasks"]) > 0 assert "name" in ansible_config["tasks"][0] class TestServiceInstallerScriptMethod: """Test script-based installation method.""" def setup_method(self): """Set up test method.""" # Create a temporary directory for templates self.temp_dir = tempfile.mkdtemp() self.template_dir = Path(self.temp_dir) / "service_templates" self.template_dir.mkdir() # Create a sample script service template self.script_template = { "name": "script-service", "description": "Test service using custom script", "category": "test", "requirements": {"ports": [3000], "memory_gb": 1, "disk_gb": 5}, "installation": { "method": "script", "script": { "pre_install": [ "sudo apt-get update", "sudo apt-get install -y curl", ], "install": [ "curl -fsSL https://nodejs.org/dist/v18.17.0/node-v18.17.0-linux-x64.tar.xz -o node.tar.xz", "tar -xf node.tar.xz", "sudo mv node-v18.17.0-linux-x64 /opt/nodejs", "sudo ln -sf /opt/nodejs/bin/node /usr/local/bin/node", "sudo ln -sf /opt/nodejs/bin/npm /usr/local/bin/npm", ], "post_install": ["node --version", "npm --version"], "service_start": ["cd /opt/app", "npm start"], }, }, } template_file = self.template_dir / "script-service.yaml" with open(template_file, "w") as f: import yaml yaml.dump(self.script_template, f) # Patch the templates directory self.patcher = patch( "src.homelab_mcp.service_installer.TEMPLATES_DIR", self.template_dir ) self.patcher.start() self.installer = ServiceInstaller() def teardown_method(self): """Tear down test method.""" self.patcher.stop() import shutil shutil.rmtree(self.temp_dir) @pytest.mark.asyncio async def test_install_script_service_success(self): """Test successful script-based service installation.""" with patch.object(self.installer, "check_service_requirements") as mock_check: mock_check.return_value = {"requirements_met": True} with patch.object( self.installer, "_install_script_service" ) as mock_install: mock_install.return_value = { "status": "success", "service": "script-service", "method": "script", "executed_commands": { "pre_install": 2, "install": 5, "post_install": 2, }, } result = await self.installer.install_service( "script-service", "test-host", "test-user", "test-pass" ) assert result["status"] == "success" assert result["method"] == "script" assert "executed_commands" in result @pytest.mark.asyncio async def test_install_script_service_command_failure(self): """Test script service installation with command failure.""" with patch.object(self.installer, "check_service_requirements") as mock_check: mock_check.return_value = {"requirements_met": True} with patch.object( self.installer, "_install_script_service" ) as mock_install: mock_install.return_value = { "status": "error", "error": "Command failed: sudo apt-get install -y curl", "failed_command": "sudo apt-get install -y curl", "exit_code": 1, "stderr": "Package not found", } result = await self.installer.install_service( "script-service", "test-host", "test-user", "test-pass" ) assert result["status"] == "error" assert "Command failed" in result["error"] assert "failed_command" in result def test_script_template_validation(self): """Test script template structure validation.""" info = self.installer.get_service_info("script-service") assert info is not None assert info["installation"]["method"] == "script" script_config = info["installation"]["script"] assert "install" in script_config assert isinstance(script_config["install"], list) assert len(script_config["install"]) > 0 # Optional sections should exist if defined if "pre_install" in script_config: assert isinstance(script_config["pre_install"], list) if "post_install" in script_config: assert isinstance(script_config["post_install"], list) class TestServiceInstallerVariableSubstitution: """Test template variable substitution functionality.""" def setup_method(self): """Set up test method.""" # Create a temporary directory for templates self.temp_dir = tempfile.mkdtemp() self.template_dir = Path(self.temp_dir) / "service_templates" self.template_dir.mkdir() # Create a template with variables self.variable_template = { "name": "variable-service", "description": "Service with {{ service_description }}", "category": "test", "requirements": { "ports": ["{{ service_port }}"], "memory_gb": "{{ memory_size }}", "disk_gb": 10, }, "installation": { "method": "docker-compose", "compose": { "version": "3.8", "services": { "{{ service_name }}": { "image": "{{ docker_image }}", "ports": ["{{ service_port }}:80"], "environment": {"ENV_VAR": "{{ env_value }}"}, } }, }, }, } template_file = self.template_dir / "variable-service.yaml" with open(template_file, "w") as f: import yaml yaml.dump(self.variable_template, f) # Patch the templates directory self.patcher = patch( "src.homelab_mcp.service_installer.TEMPLATES_DIR", self.template_dir ) self.patcher.start() self.installer = ServiceInstaller() def teardown_method(self): """Tear down test method.""" self.patcher.stop() import shutil shutil.rmtree(self.temp_dir) @pytest.mark.asyncio async def test_variable_substitution_in_installation(self): """Test variable substitution during service installation.""" variables = { "service_name": "custom-app", "service_description": "custom application", "service_port": 8080, "memory_size": 2, "docker_image": "nginx:alpine", "env_value": "production", } with patch.object(self.installer, "check_service_requirements") as mock_check: mock_check.return_value = {"requirements_met": True} with patch.object( self.installer, "_install_docker_compose_service" ) as mock_install: mock_install.return_value = {"status": "success"} await self.installer.install_service( "variable-service", "test-host", "test-user", "test-pass", variables ) # Verify that template was passed with variables mock_install.assert_called_once() call_args = mock_install.call_args call_args[0][1] # template parameter # Variables should be substituted in the template # This would be verified in the actual implementation assert call_args[0][5] == variables # variables parameter def test_get_template_with_defaults(self): """Test getting template info with default variable values.""" # Add defaults to template template_with_defaults = self.variable_template.copy() template_with_defaults["defaults"] = { "service_port": 3000, "memory_size": 1, "docker_image": "nginx:latest", "service_name": "default-service", } template_file = self.template_dir / "defaults-service.yaml" with open(template_file, "w") as f: import yaml yaml.dump(template_with_defaults, f) # Reload installer to pick up new template installer = ServiceInstaller() info = installer.get_service_info("defaults-service") assert info is not None assert "defaults" in info assert info["defaults"]["service_port"] == 3000 @pytest.mark.asyncio async def test_missing_required_variables(self): """Test installation failure when required variables are missing.""" # Provide only partial variables incomplete_variables = { "service_name": "test-app", # Missing other required variables } with patch.object(self.installer, "check_service_requirements") as mock_check: mock_check.return_value = {"requirements_met": True} result = await self.installer.install_service( "variable-service", "test-host", "test-user", "test-pass", incomplete_variables, ) # Should return error about missing variables # This would be implemented in the actual service installer # For now, we test the basic structure is maintained assert "service" in result or "status" in result class TestServiceInstallerISOMethod: """Test ISO installation method.""" def setup_method(self): """Set up test method.""" # Create a temporary directory for templates self.temp_dir = tempfile.mkdtemp() self.template_dir = Path(self.temp_dir) / "service_templates" self.template_dir.mkdir() # Create a sample ISO service template self.iso_template = { "name": "truenas-iso", "description": "TrueNAS installation via ISO", "category": "storage", "requirements": {"memory_gb": 8, "disk_gb": 20, "cpu_cores": 2}, "installation": { "method": "iso_installation", "iso": { "download_url": "https://download.truenas.com/TrueNAS-SCALE-22.12.0.iso", "checksum": "sha256:abc123...", "boot_options": {"console": "ttyS0", "quiet": True}, "installation_guide": "Manual installation required. Boot from ISO and follow setup wizard.", "post_install_notes": "Access web interface at https://<ip>:443 after installation.", }, }, } template_file = self.template_dir / "truenas-iso.yaml" with open(template_file, "w") as f: import yaml yaml.dump(self.iso_template, f) # Patch the templates directory self.patcher = patch( "src.homelab_mcp.service_installer.TEMPLATES_DIR", self.template_dir ) self.patcher.start() self.installer = ServiceInstaller() def teardown_method(self): """Tear down test method.""" self.patcher.stop() import shutil shutil.rmtree(self.temp_dir) @pytest.mark.asyncio async def test_install_iso_service_guidance(self): """Test ISO service installation provides proper guidance.""" with patch.object(self.installer, "check_service_requirements") as mock_check: mock_check.return_value = {"requirements_met": True} with patch.object(self.installer, "_install_iso_service") as mock_install: mock_install.return_value = { "status": "guidance_provided", "service": "truenas-iso", "method": "iso_installation", "download_url": "https://download.truenas.com/TrueNAS-SCALE-22.12.0.iso", "installation_guide": "Manual installation required. Boot from ISO and follow setup wizard.", "post_install_notes": "Access web interface at https://<ip>:443 after installation.", "next_steps": [ "Download ISO from provided URL", "Create bootable media", "Boot target system from ISO", "Follow installation wizard", ], } result = await self.installer.install_service( "truenas-iso", "test-host", "test-user", "test-pass" ) assert result["status"] == "guidance_provided" assert result["method"] == "iso_installation" assert "download_url" in result assert "installation_guide" in result assert "next_steps" in result def test_iso_template_validation(self): """Test ISO template structure validation.""" info = self.installer.get_service_info("truenas-iso") assert info is not None assert info["installation"]["method"] == "iso_installation" iso_config = info["installation"]["iso"] assert "download_url" in iso_config assert "installation_guide" in iso_config # Verify URL format assert iso_config["download_url"].startswith("http") assert iso_config["download_url"].endswith(".iso")

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/washyu/mcp_python_server'

If you have feedback or need assistance with the MCP directory API, please join our Discord server