diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md deleted file mode 100644 index e613a94..0000000 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ /dev/null @@ -1,51 +0,0 @@ -## Description - - -## Related Issue - -Fixes # - -## Type of Change - -- [ ] Bug fix (non-breaking change which fixes an issue) -- [ ] New feature (non-breaking change which adds functionality) -- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) -- [ ] Documentation update -- [ ] Refactoring (no functional changes) - -## Checklist - -- [ ] My code follows the style guidelines of this project (black, ruff) -- [ ] I have performed a self-review of my code -- [ ] I have commented my code, particularly in hard-to-understand areas -- [ ] I have made corresponding changes to the documentation -- [ ] My changes generate no new warnings -- [ ] I have added tests that prove my fix is effective or that my feature works -- [ ] New and existing unit tests pass locally with my changes -- [ ] Any dependent changes have been merged and published - -## Testing - - -### Unit Tests -```bash -pytest tests/unit/ -v -``` - -### Code Quality -```bash -black --check stackbox/ tests/ -ruff check stackbox/ tests/ -mypy stackbox/core/compose.py -``` - -### Coverage -```bash -pytest --cov=stackbox --cov-report=term-missing -``` - -## Screenshots (if applicable) - - -## Additional Notes - diff --git a/pyproject.toml b/pyproject.toml index 256e87a..60028f3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,6 +42,10 @@ dependencies = [ "jinja2>=3.1.0", ] +# CLI entry points +[project.scripts] +sb = "stackbox.cli.__main__:main" + [tool.setuptools.dynamic] version = {attr = "stackbox.__version__"} diff --git a/stackbox/cli/__init__.py b/stackbox/cli/__init__.py index e69de29..b42cbe7 100644 --- a/stackbox/cli/__init__.py +++ b/stackbox/cli/__init__.py @@ -0,0 +1,5 @@ +"""StackBox CLI package.""" + +from stackbox.cli.__main__ import cli, main + +__all__ = ["cli", "main"] diff --git a/stackbox/cli/__main__.py b/stackbox/cli/__main__.py new file mode 100644 index 0000000..b2bb5ef --- /dev/null +++ b/stackbox/cli/__main__.py @@ -0,0 +1,247 @@ +"""StackBox CLI entry point - class-based architecture.""" + +import click + +from stackbox import __version__ + + +class StackBoxCLI: + """StackBox command-line interface. + + Provides commands for managing containerized Ironic development environments + with fast rebuild cycles. + """ + + def __init__(self) -> None: + """Initialize the CLI with all commands.""" + self.cli = click.Group( + name="sb", + help=self._get_main_help(), + context_settings={"help_option_names": ["-h", "--help"]}, + ) + self._register_commands() + + def _get_main_help(self) -> str: + """Get main CLI help text.""" + return """StackBox - Fast OpenStack CI job reproduction + + Reproduce Ironic CI jobs locally in containers with fast rebuild cycles. + + Quick start: + sb init ironic-basic --ironic-repo ~/code/ironic + sb test + sb rebuild ironic + + Get help on any command: + sb --help + """ + + def _register_commands(self) -> None: + """Register all CLI commands.""" + # Add version option to main group + self.cli = click.version_option( + version=__version__, prog_name="sb", message="%(prog)s version %(version)s" + )(self.cli) + + # Register command methods + self.cli.add_command(self._create_init_command()) + self.cli.add_command(self._create_rebuild_command()) + self.cli.add_command(self._create_test_command()) + self.cli.add_command(self._create_status_command()) + self.cli.add_command(self._create_logs_command()) + self.cli.add_command(self._create_down_command()) + + def _create_init_command(self) -> click.Command: + """Create the init command.""" + + @click.command(name="init") + @click.argument("job-name") + @click.option( + "--ironic-repo", + default="~/workspace/ironic", + type=click.Path(), + help="Path to local Ironic repository", + ) + @click.option( + "--config-dir", + default=".stackbox", + type=click.Path(), + help="Directory to store StackBox configuration", + ) + def init_cmd(job_name: str, ironic_repo: str, config_dir: str) -> None: + """Initialize environment for a CI job. + + Sets up infrastructure containers, builds Ironic, creates test nodes, + and configures Tempest. + + Example: + sb init ironic-basic --ironic-repo ~/code/ironic + """ + self.init(job_name, ironic_repo, config_dir) + + return init_cmd + + def _create_rebuild_command(self) -> click.Command: + """Create the rebuild command.""" + + @click.command(name="rebuild") + @click.argument("service", default="ironic") + @click.option("--no-cache", is_flag=True, help="Force rebuild without using cache") + def rebuild_cmd(service: str, no_cache: bool) -> None: + """Rebuild a service after code changes. + + Rebuilds the Docker image and restarts containers. + + Example: + sb rebuild ironic + sb rebuild ironic --no-cache + """ + self.rebuild(service, no_cache) + + return rebuild_cmd + + def _create_test_command(self) -> click.Command: + """Create the test command.""" + + @click.command(name="test") + @click.option("--regex", help="Test regex pattern to run (e.g., test_baremetal_basic_ops)") + @click.option("--verbose", "-v", is_flag=True, help="Show verbose output") + def test_cmd(regex: str | None, verbose: bool) -> None: + """Run Tempest tests. + + Runs ironic-tempest-plugin tests in the environment. + + Example: + sb test + sb test --regex test_baremetal_basic_ops + sb test --verbose + """ + self.test(regex, verbose) + + return test_cmd + + def _create_status_command(self) -> click.Command: + """Create the status command.""" + + @click.command(name="status") + @click.option("--json", is_flag=True, help="Output status as JSON") + def status_cmd(json: bool) -> None: + """Show environment status. + + Displays running containers, health status, and endpoints. + + Example: + sb status + sb status --json + """ + self.status(json) + + return status_cmd + + def _create_logs_command(self) -> click.Command: + """Create the logs command.""" + + @click.command(name="logs") + @click.argument("service") + @click.option("--follow", "-f", is_flag=True, help="Follow log output (like tail -f)") + @click.option( + "--tail", default=100, type=int, help="Number of lines to show from end of logs" + ) + def logs_cmd(service: str, follow: bool, tail: int) -> None: + """View service logs. + + Shows logs from a specific service container. + + Example: + sb logs ironic-conductor + sb logs ironic-conductor --follow --tail 50 + """ + self.logs(service, follow, tail) + + return logs_cmd + + def _create_down_command(self) -> click.Command: + """Create the down command.""" + + @click.command(name="down") + @click.option("--volumes", is_flag=True, help="Also remove volumes (deletes all data)") + @click.confirmation_option(prompt="Are you sure you want to stop the environment?") + def down_cmd(volumes: bool) -> None: + """Stop and remove environment. + + Stops all containers and optionally removes volumes. + + Example: + sb down + sb down --volumes # Also delete data + """ + self.down(volumes) + + return down_cmd + + # ======================================================================== + # Command Implementation Methods + # ======================================================================== + + def init(self, job_name: str, ironic_repo: str, config_dir: str) -> None: + """Initialize environment for a CI job.""" + click.echo(f"Initializing {job_name}...") + click.echo(f" Ironic repo: {ironic_repo}") + click.echo(f" Config dir: {config_dir}") + click.echo("\n⚠️ Not implemented yet (Issue #9)") + + def rebuild(self, service: str, no_cache: bool) -> None: + """Rebuild a service after code changes.""" + click.echo(f"Rebuilding {service}...") + if no_cache: + click.echo(" Using --no-cache") + click.echo("\n⚠️ Not implemented yet (Issue #16)") + + def test(self, regex: str | None, verbose: bool) -> None: + """Run Tempest tests.""" + if regex: + click.echo(f"Running tests matching: {regex}") + else: + click.echo("Running all tests...") + + if verbose: + click.echo(" Verbose mode enabled") + + click.echo("\n⚠️ Not implemented yet (Issue #13)") + + def status(self, json: bool) -> None: + """Show environment status.""" + click.echo("Checking environment status...") + if json: + click.echo('{"status": "not_implemented"}') + else: + click.echo("\n⚠️ Not implemented yet (Issue #20)") + + def logs(self, service: str, follow: bool, tail: int) -> None: + """View service logs.""" + click.echo(f"Showing logs for {service}") + click.echo(f" Tail: {tail} lines") + if follow: + click.echo(" Following logs...") + click.echo("\n⚠️ Not implemented yet (Issue #21)") + + def down(self, volumes: bool) -> None: + """Stop and remove environment.""" + click.echo("Stopping environment...") + if volumes: + click.echo(" Will also remove volumes") + click.echo("\n⚠️ Not implemented yet (Issue #22)") + + +# Module-level CLI instance +_cli_instance = StackBoxCLI() +cli = _cli_instance.cli + + +def main() -> None: + """Main entry point for the CLI.""" + cli() + + +if __name__ == "__main__": + main() diff --git a/stackbox/core/compose.py b/stackbox/core/compose.py new file mode 100644 index 0000000..b3411aa --- /dev/null +++ b/stackbox/core/compose.py @@ -0,0 +1,220 @@ +"""Docker Compose infrastructure management for StackBox.""" + +import contextlib +from pathlib import Path +import subprocess + +from jinja2 import Template +import yaml + + +def generate_compose_file(output_path: Path, config: dict[str, str] | None = None) -> Path: + """ + Generate docker-compose.yml from Jinja2 template. + + Args: + output_path: Path where the generated docker-compose.yml should be written + config: Configuration dictionary with optional keys: + - db_password: MariaDB password (default: "stackbox-secret") + - rabbit_password: RabbitMQ password (default: "stackbox-secret") + + Returns: + Path to the generated docker-compose.yml file + + Raises: + FileNotFoundError: If template file doesn't exist + yaml.YAMLError: If generated content is invalid YAML + PermissionError: If output_path is not writable + """ + if config is None: + config = {} + + # Set defaults + config.setdefault("db_password", "stackbox-secret") + config.setdefault("rabbit_password", "stackbox-secret") + + # Locate template + template_path = Path(__file__).parent.parent / "templates" / "docker-compose.yml.j2" + + if not template_path.exists(): + raise FileNotFoundError(f"Template not found: {template_path}") + + # Render template + template = Template(template_path.read_text()) + rendered = template.render(**config) + + # Validate YAML (catch errors early) + try: + yaml.safe_load(rendered) + except yaml.YAMLError as e: + raise yaml.YAMLError(f"Generated content is invalid YAML: {e}") from e + + # Write to output + output_path.write_text(rendered) + + return output_path + + +def _check_docker_running() -> bool: + """Check if Docker daemon is running.""" + try: + subprocess.run( + ["docker", "ps"], + check=True, + capture_output=True, + text=True, + timeout=5, + ) + return True + except (subprocess.CalledProcessError, FileNotFoundError, subprocess.TimeoutExpired): + return False + + +def _check_port_available(port: int) -> bool: + """Check if a port is available (not in use).""" + try: + result = subprocess.run( + ["ss", "-tlnp"], + capture_output=True, + text=True, + timeout=5, + ) + # Check if port appears in listening state + return f":{port}" not in result.stdout + except (subprocess.CalledProcessError, FileNotFoundError, subprocess.TimeoutExpired): + # If ss command fails, fall back to basic check + import socket + + try: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.bind(("127.0.0.1", port)) + return True + except OSError: + return False + + +def _check_ports_available(ports: list[int]) -> tuple[bool, list[int]]: + """Check if multiple ports are available. + + Returns: + Tuple of (all_available, list_of_unavailable_ports) + """ + unavailable = [port for port in ports if not _check_port_available(port)] + return (len(unavailable) == 0, unavailable) + + +def _check_kvm_available() -> bool: + """Check if KVM is available for libvirt.""" + kvm_device = Path("/dev/kvm") + return kvm_device.exists() and kvm_device.is_char_device() + + +def validate_environment() -> dict[str, bool | list[int]]: + """ + Run pre-flight checks for infrastructure requirements. + + Returns: + Dictionary with validation results: + - docker_running: bool + - ports_available: bool + - unavailable_ports: list[int] + - kvm_available: bool + """ + required_ports = [3306, 5672, 8000, 16509, 15672] + ports_ok, unavailable = _check_ports_available(required_ports) + + return { + "docker_running": _check_docker_running(), + "ports_available": ports_ok, + "unavailable_ports": unavailable, + "kvm_available": _check_kvm_available(), + } + + +def start_infrastructure(compose_file: Path, skip_validation: bool = False) -> None: + """ + Start infrastructure services using docker-compose. + + Args: + compose_file: Path to the docker-compose.yml file + skip_validation: Skip pre-flight validation checks (not recommended) + + Raises: + RuntimeError: If Docker daemon is not running or if compose file doesn't exist + subprocess.CalledProcessError: If docker-compose command fails + """ + if not compose_file.exists(): + raise RuntimeError(f"Compose file not found: {compose_file}") + + # Run pre-flight checks + if not skip_validation: + validation = validate_environment() + + if not validation["docker_running"]: + raise RuntimeError( + "Docker daemon not running. Start it with: sudo systemctl start docker" + ) + + if not validation["ports_available"]: + ports = ", ".join(str(p) for p in validation["unavailable_ports"]) + raise RuntimeError( + f"Required ports already in use: {ports}\n" + f"Check with: sudo ss -tlnp | grep ''\n" + f"Or stop conflicting containers: docker ps" + ) + + if not validation["kvm_available"]: + raise RuntimeError( + "KVM not available at /dev/kvm. Libvirt requires hardware virtualization.\n" + "Solutions:\n" + " 1. Enable CPU virtualization in BIOS/UEFI\n" + " 2. Install libvirt: sudo apt install libvirt-daemon-system\n" + " 3. Ensure your user is in the 'kvm' group: sudo usermod -a -G kvm $USER" + ) + + # Start services + try: + subprocess.run( + ["docker-compose", "-f", str(compose_file), "up", "-d"], + check=True, + capture_output=True, + text=True, + ) + except subprocess.CalledProcessError as e: + # Parse stderr for common issues + if "port is already allocated" in e.stderr: + raise RuntimeError( + f"Port conflict detected. Check running containers with 'docker ps'.\n" + f"Details: {e.stderr}" + ) from e + if "Cannot connect to the Docker daemon" in e.stderr: + raise RuntimeError( + f"Docker daemon not accessible.\n" + f"Check: sudo systemctl status docker\n" + f"Details: {e.stderr}" + ) from e + raise RuntimeError(f"Failed to start infrastructure: {e.stderr}") from e + + +def stop_infrastructure(compose_file: Path, remove_volumes: bool = False) -> None: + """ + Stop infrastructure services. + + Args: + compose_file: Path to the docker-compose.yml file + remove_volumes: If True, remove volumes after stopping (clean slate) + + Raises: + subprocess.CalledProcessError: If docker-compose command fails + """ + if not compose_file.exists(): + # Gracefully handle missing file (already cleaned up) + return + + cmd = ["docker-compose", "-f", str(compose_file), "down"] + if remove_volumes: + cmd.append("-v") + + # Don't raise if services already stopped + with contextlib.suppress(subprocess.CalledProcessError): + subprocess.run(cmd, check=True, capture_output=True, text=True) diff --git a/stackbox/templates/docker-compose.yml.j2 b/stackbox/templates/docker-compose.yml.j2 new file mode 100644 index 0000000..4503a09 --- /dev/null +++ b/stackbox/templates/docker-compose.yml.j2 @@ -0,0 +1,97 @@ +version: '3.8' + +services: + mariadb: + image: mariadb:11.2 + container_name: stackbox-mariadb + environment: + MYSQL_ROOT_PASSWORD: {{ db_password }} + MYSQL_DATABASE: ironic + MYSQL_USER: ironic + MYSQL_PASSWORD: {{ db_password }} + volumes: + - mariadb-data:/var/lib/mysql + ports: + - "3306:3306" + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-p{{ db_password }}"] + interval: 10s + timeout: 5s + retries: 5 + networks: + - stackbox + + rabbitmq: + image: rabbitmq:3.12-management + container_name: stackbox-rabbitmq + environment: + RABBITMQ_DEFAULT_USER: stackrabbit + RABBITMQ_DEFAULT_PASS: {{ rabbit_password }} + ports: + - "5672:5672" + - "15672:15672" + healthcheck: + test: ["CMD", "rabbitmqctl", "status"] + interval: 10s + timeout: 5s + retries: 5 + networks: + - stackbox + + # Libvirt container (runs VMs inside container - Metal3 style) + libvirt: + image: quay.io/metal3-io/ironic:latest + container_name: stackbox-libvirt + privileged: true + networks: + - stackbox + volumes: + - /dev:/dev + - /sys/fs/cgroup:/sys/fs/cgroup:ro + - libvirt-data:/var/lib/libvirt + - libvirt-images:/var/lib/libvirt/images + environment: + - LIBVIRT_DEFAULT_URI=qemu:///system + command: libvirtd --listen + ports: + - "16509:16509" + healthcheck: + test: ["CMD", "virsh", "version"] + interval: 10s + timeout: 5s + retries: 5 + restart: unless-stopped + + # Sushy-tools (Redfish BMC emulator) - connects to containerized libvirt + sushy-tools: + image: quay.io/metal3-io/sushy-tools:latest + container_name: stackbox-sushy-tools + networks: + - stackbox + ports: + - "8000:8000" + environment: + - SUSHY_EMULATOR_LIBVIRT_URI=qemu+tcp://libvirt:16509/system + - SUSHY_EMULATOR_LISTEN_IP=0.0.0.0 + - SUSHY_EMULATOR_LISTEN_PORT=8000 + depends_on: + libvirt: + condition: service_healthy + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8000/redfish/v1/"] + interval: 10s + timeout: 5s + retries: 5 + restart: unless-stopped + +volumes: + mariadb-data: + name: stackbox-mariadb-data + libvirt-data: + name: stackbox-libvirt-data + libvirt-images: + name: stackbox-libvirt-images + +networks: + stackbox: + name: stackbox-network diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py new file mode 100644 index 0000000..8d8dd4e --- /dev/null +++ b/tests/unit/__init__.py @@ -0,0 +1 @@ +"""Unit tests for StackBox.""" diff --git a/tests/unit/core/__init__.py b/tests/unit/core/__init__.py new file mode 100644 index 0000000..1b8a740 --- /dev/null +++ b/tests/unit/core/__init__.py @@ -0,0 +1 @@ +"""Core module unit tests.""" diff --git a/tests/unit/core/test_compose.py b/tests/unit/core/test_compose.py new file mode 100644 index 0000000..56fc962 --- /dev/null +++ b/tests/unit/core/test_compose.py @@ -0,0 +1,252 @@ +"""Tests for stackbox.core.compose module.""" + +from pathlib import Path +import subprocess +from unittest.mock import MagicMock, patch + +import pytest +import yaml + +from stackbox.core.compose import ( + generate_compose_file, + start_infrastructure, + stop_infrastructure, +) + + +class TestGenerateComposeFile: + """Tests for generate_compose_file function.""" + + def test_generate_with_defaults(self, tmp_path: Path) -> None: + """Test that default passwords are applied.""" + output = tmp_path / "docker-compose.yml" + result = generate_compose_file(output) + + assert result == output + assert output.exists() + + content = output.read_text() + assert "stackbox-secret" in content + + def test_generate_with_custom_config(self, tmp_path: Path) -> None: + """Test that custom passwords are used.""" + output = tmp_path / "docker-compose.yml" + config = { + "db_password": "custom-db-pass", + "rabbit_password": "custom-rabbit-pass", + } + + generate_compose_file(output, config) + + content = output.read_text() + assert "custom-db-pass" in content + assert "custom-rabbit-pass" in content + assert "stackbox-secret" not in content + + def test_generated_file_is_valid_yaml(self, tmp_path: Path) -> None: + """Test that generated file is valid YAML.""" + output = tmp_path / "docker-compose.yml" + generate_compose_file(output) + + # Should not raise + with output.open() as f: + data = yaml.safe_load(f) + + assert isinstance(data, dict) + assert "services" in data + assert "volumes" in data + assert "networks" in data + + def test_template_contains_all_services(self, tmp_path: Path) -> None: + """Test that all required services are present.""" + output = tmp_path / "docker-compose.yml" + generate_compose_file(output) + + with output.open() as f: + data = yaml.safe_load(f) + + services = data["services"] + assert "mariadb" in services + assert "rabbitmq" in services + assert "libvirt" in services + assert "sushy-tools" in services + + def test_template_network_configuration(self, tmp_path: Path) -> None: + """Test that network is correctly configured.""" + output = tmp_path / "docker-compose.yml" + generate_compose_file(output) + + with output.open() as f: + data = yaml.safe_load(f) + + networks = data["networks"] + assert "stackbox" in networks + assert networks["stackbox"]["name"] == "stackbox-network" + + # Verify all services use the network + for service in data["services"].values(): + assert "stackbox" in service["networks"] + + def test_template_volume_configuration(self, tmp_path: Path) -> None: + """Test that volumes are correctly configured.""" + output = tmp_path / "docker-compose.yml" + generate_compose_file(output) + + with output.open() as f: + data = yaml.safe_load(f) + + volumes = data["volumes"] + assert "mariadb-data" in volumes + assert "libvirt-data" in volumes + assert "libvirt-images" in volumes + + assert volumes["mariadb-data"]["name"] == "stackbox-mariadb-data" + assert volumes["libvirt-data"]["name"] == "stackbox-libvirt-data" + assert volumes["libvirt-images"]["name"] == "stackbox-libvirt-images" + + def test_libvirt_uses_containerized_uri(self, tmp_path: Path) -> None: + """Test that sushy-tools connects to containerized libvirt.""" + output = tmp_path / "docker-compose.yml" + generate_compose_file(output) + + with output.open() as f: + data = yaml.safe_load(f) + + sushy_env = data["services"]["sushy-tools"]["environment"] + libvirt_uri = next( + (e for e in sushy_env if e.startswith("SUSHY_EMULATOR_LIBVIRT_URI=")), None + ) + + assert libvirt_uri is not None + # Must use containerized libvirt, not host + assert "qemu+tcp://libvirt:16509/system" in libvirt_uri + assert "qemu:///system" not in libvirt_uri + + def test_health_checks_configured(self, tmp_path: Path) -> None: + """Test that all services have health checks.""" + output = tmp_path / "docker-compose.yml" + generate_compose_file(output) + + with output.open() as f: + data = yaml.safe_load(f) + + for service_name, service in data["services"].items(): + assert "healthcheck" in service, f"{service_name} missing healthcheck" + assert "test" in service["healthcheck"] + + +class TestStartInfrastructure: + """Tests for start_infrastructure function.""" + + @patch("subprocess.run") + def test_start_calls_docker_compose_correctly( + self, mock_run: MagicMock, tmp_path: Path + ) -> None: + """Test that start_infrastructure calls docker-compose with correct args.""" + compose_file = tmp_path / "docker-compose.yml" + compose_file.write_text("version: '3.8'") + + # Skip validation to test only the docker-compose call + start_infrastructure(compose_file, skip_validation=True) + + mock_run.assert_called_once_with( + ["docker-compose", "-f", str(compose_file), "up", "-d"], + check=True, + capture_output=True, + text=True, + ) + + @patch("stackbox.core.compose.validate_environment") + def test_start_raises_if_docker_not_running( + self, mock_validate: MagicMock, tmp_path: Path + ) -> None: + """Test that error is raised if Docker daemon not running.""" + compose_file = tmp_path / "docker-compose.yml" + compose_file.write_text("version: '3.8'") + + mock_validate.return_value = { + "docker_running": False, + "ports_available": True, + "unavailable_ports": [], + "kvm_available": True, + } + + with pytest.raises(RuntimeError, match="Docker daemon not running"): + start_infrastructure(compose_file) + + def test_start_raises_if_compose_file_missing(self, tmp_path: Path) -> None: + """Test that error is raised if compose file doesn't exist.""" + compose_file = tmp_path / "nonexistent.yml" + + with pytest.raises(RuntimeError, match="Compose file not found"): + start_infrastructure(compose_file) + + @patch("subprocess.run") + def test_start_handles_port_conflict(self, mock_run: MagicMock, tmp_path: Path) -> None: + """Test that port conflict errors are handled gracefully.""" + compose_file = tmp_path / "docker-compose.yml" + compose_file.write_text("version: '3.8'") + + # Simulate port conflict + error = subprocess.CalledProcessError( + 1, "docker-compose", stderr="port is already allocated" + ) + mock_run.side_effect = error + + with pytest.raises(RuntimeError, match="Port conflict detected"): + start_infrastructure(compose_file, skip_validation=True) + + +class TestStopInfrastructure: + """Tests for stop_infrastructure function.""" + + @patch("subprocess.run") + def test_stop_calls_docker_compose_correctly(self, mock_run: MagicMock, tmp_path: Path) -> None: + """Test that stop_infrastructure calls docker-compose with correct args.""" + compose_file = tmp_path / "docker-compose.yml" + compose_file.write_text("version: '3.8'") + + stop_infrastructure(compose_file) + + mock_run.assert_called_once_with( + ["docker-compose", "-f", str(compose_file), "down"], + check=True, + capture_output=True, + text=True, + ) + + @patch("subprocess.run") + def test_stop_with_remove_volumes(self, mock_run: MagicMock, tmp_path: Path) -> None: + """Test that -v flag is added when remove_volumes=True.""" + compose_file = tmp_path / "docker-compose.yml" + compose_file.write_text("version: '3.8'") + + stop_infrastructure(compose_file, remove_volumes=True) + + mock_run.assert_called_once_with( + ["docker-compose", "-f", str(compose_file), "down", "-v"], + check=True, + capture_output=True, + text=True, + ) + + def test_stop_handles_missing_file_gracefully(self, tmp_path: Path) -> None: + """Test that missing compose file doesn't raise error.""" + compose_file = tmp_path / "nonexistent.yml" + + # Should not raise + stop_infrastructure(compose_file) + + @patch("subprocess.run") + def test_stop_handles_already_stopped_services( + self, mock_run: MagicMock, tmp_path: Path + ) -> None: + """Test that already stopped services don't cause errors.""" + compose_file = tmp_path / "docker-compose.yml" + compose_file.write_text("version: '3.8'") + + # Simulate services already stopped + mock_run.side_effect = subprocess.CalledProcessError(1, "docker-compose") + + # Should not raise + stop_infrastructure(compose_file) diff --git a/tests/unit/core/test_validation.py b/tests/unit/core/test_validation.py new file mode 100644 index 0000000..75b383a --- /dev/null +++ b/tests/unit/core/test_validation.py @@ -0,0 +1,259 @@ +"""Tests for infrastructure validation functions.""" + +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +from stackbox.core.compose import ( + _check_kvm_available, + _check_port_available, + _check_ports_available, + validate_environment, +) + + +class TestPortValidation: + """Tests for port availability checking.""" + + @patch("subprocess.run") + def test_check_port_available_with_ss_command(self, mock_run: MagicMock) -> None: + """Test port check using ss command.""" + # Mock ss output showing port 3306 is in use + mock_run.return_value = MagicMock( + stdout="tcp LISTEN 0 80 127.0.0.1:3306 0.0.0.0:*" + ) + + # Port 3306 should not be available + assert not _check_port_available(3306) + + # Port 8080 should be available (not in output) + assert _check_port_available(8080) + + @patch("subprocess.run") + @patch("socket.socket") + def test_check_port_available_fallback_to_socket( + self, mock_socket: MagicMock, mock_run: MagicMock + ) -> None: + """Test port check fallback when ss command fails.""" + # Mock ss command failure + mock_run.side_effect = FileNotFoundError() + + # Mock socket bind success (port available) + mock_socket_instance = MagicMock() + mock_socket.return_value.__enter__.return_value = mock_socket_instance + + assert _check_port_available(8080) + mock_socket_instance.bind.assert_called_once_with(("127.0.0.1", 8080)) + + @patch("subprocess.run") + @patch("socket.socket") + def test_check_port_available_socket_in_use( + self, mock_socket: MagicMock, mock_run: MagicMock + ) -> None: + """Test port check when socket bind fails.""" + # Mock ss command failure + mock_run.side_effect = FileNotFoundError() + + # Mock socket bind failure (port in use) + mock_socket_instance = MagicMock() + mock_socket_instance.bind.side_effect = OSError("Address already in use") + mock_socket.return_value.__enter__.return_value = mock_socket_instance + + assert not _check_port_available(3306) + + @patch("stackbox.core.compose._check_port_available") + def test_check_ports_available_all_free(self, mock_check: MagicMock) -> None: + """Test checking multiple ports when all are free.""" + mock_check.return_value = True + + all_available, unavailable = _check_ports_available([3306, 5672, 8000]) + + assert all_available is True + assert unavailable == [] + + @patch("stackbox.core.compose._check_port_available") + def test_check_ports_available_some_in_use(self, mock_check: MagicMock) -> None: + """Test checking multiple ports when some are in use.""" + # Mock: ports 3306 and 5672 in use, 8000 available + mock_check.side_effect = [False, False, True] + + all_available, unavailable = _check_ports_available([3306, 5672, 8000]) + + assert all_available is False + assert unavailable == [3306, 5672] + + +class TestKVMValidation: + """Tests for KVM availability checking.""" + + @patch("pathlib.Path.exists") + @patch("pathlib.Path.is_char_device") + def test_kvm_available(self, mock_is_char: MagicMock, mock_exists: MagicMock) -> None: + """Test KVM check when /dev/kvm exists and is a character device.""" + mock_exists.return_value = True + mock_is_char.return_value = True + + assert _check_kvm_available() is True + + @patch("pathlib.Path.exists") + def test_kvm_not_exists(self, mock_exists: MagicMock) -> None: + """Test KVM check when /dev/kvm doesn't exist.""" + mock_exists.return_value = False + + assert _check_kvm_available() is False + + @patch("pathlib.Path.exists") + @patch("pathlib.Path.is_char_device") + def test_kvm_exists_but_not_char_device( + self, mock_is_char: MagicMock, mock_exists: MagicMock + ) -> None: + """Test KVM check when /dev/kvm exists but isn't a character device.""" + mock_exists.return_value = True + mock_is_char.return_value = False + + assert _check_kvm_available() is False + + +class TestEnvironmentValidation: + """Tests for complete environment validation.""" + + @patch("stackbox.core.compose._check_kvm_available") + @patch("stackbox.core.compose._check_ports_available") + @patch("stackbox.core.compose._check_docker_running") + def test_validate_environment_all_ok( + self, + mock_docker: MagicMock, + mock_ports: MagicMock, + mock_kvm: MagicMock, + ) -> None: + """Test environment validation when everything is OK.""" + mock_docker.return_value = True + mock_ports.return_value = (True, []) + mock_kvm.return_value = True + + result = validate_environment() + + assert result["docker_running"] is True + assert result["ports_available"] is True + assert result["unavailable_ports"] == [] + assert result["kvm_available"] is True + + @patch("stackbox.core.compose._check_kvm_available") + @patch("stackbox.core.compose._check_ports_available") + @patch("stackbox.core.compose._check_docker_running") + def test_validate_environment_docker_not_running( + self, + mock_docker: MagicMock, + mock_ports: MagicMock, + mock_kvm: MagicMock, + ) -> None: + """Test environment validation when Docker is not running.""" + mock_docker.return_value = False + mock_ports.return_value = (True, []) + mock_kvm.return_value = True + + result = validate_environment() + + assert result["docker_running"] is False + + @patch("stackbox.core.compose._check_kvm_available") + @patch("stackbox.core.compose._check_ports_available") + @patch("stackbox.core.compose._check_docker_running") + def test_validate_environment_ports_in_use( + self, + mock_docker: MagicMock, + mock_ports: MagicMock, + mock_kvm: MagicMock, + ) -> None: + """Test environment validation when ports are in use.""" + mock_docker.return_value = True + mock_ports.return_value = (False, [3306, 5672]) + mock_kvm.return_value = True + + result = validate_environment() + + assert result["ports_available"] is False + assert result["unavailable_ports"] == [3306, 5672] + + @patch("stackbox.core.compose._check_kvm_available") + @patch("stackbox.core.compose._check_ports_available") + @patch("stackbox.core.compose._check_docker_running") + def test_validate_environment_kvm_not_available( + self, + mock_docker: MagicMock, + mock_ports: MagicMock, + mock_kvm: MagicMock, + ) -> None: + """Test environment validation when KVM is not available.""" + mock_docker.return_value = True + mock_ports.return_value = (True, []) + mock_kvm.return_value = False + + result = validate_environment() + + assert result["kvm_available"] is False + + +class TestStartInfrastructureWithValidation: + """Tests for start_infrastructure with validation.""" + + @patch("stackbox.core.compose.validate_environment") + @patch("subprocess.run") + def test_start_fails_when_ports_in_use( + self, mock_run: MagicMock, mock_validate: MagicMock, tmp_path: Path + ) -> None: + """Test that start fails with helpful message when ports are in use.""" + compose_file = tmp_path / "docker-compose.yml" + compose_file.write_text("version: '3.8'") + + mock_validate.return_value = { + "docker_running": True, + "ports_available": False, + "unavailable_ports": [3306, 5672], + "kvm_available": True, + } + + from stackbox.core.compose import start_infrastructure + + with pytest.raises(RuntimeError, match="Required ports already in use: 3306, 5672"): + start_infrastructure(compose_file) + + @patch("stackbox.core.compose.validate_environment") + @patch("subprocess.run") + def test_start_fails_when_kvm_not_available( + self, mock_run: MagicMock, mock_validate: MagicMock, tmp_path: Path + ) -> None: + """Test that start fails with helpful message when KVM is not available.""" + compose_file = tmp_path / "docker-compose.yml" + compose_file.write_text("version: '3.8'") + + mock_validate.return_value = { + "docker_running": True, + "ports_available": True, + "unavailable_ports": [], + "kvm_available": False, + } + + from stackbox.core.compose import start_infrastructure + + with pytest.raises(RuntimeError, match="KVM not available at /dev/kvm"): + start_infrastructure(compose_file) + + @patch("stackbox.core.compose.validate_environment") + @patch("subprocess.run") + def test_start_skips_validation_when_requested( + self, mock_run: MagicMock, mock_validate: MagicMock, tmp_path: Path + ) -> None: + """Test that validation can be skipped if needed.""" + compose_file = tmp_path / "docker-compose.yml" + compose_file.write_text("version: '3.8'") + + from stackbox.core.compose import start_infrastructure + + start_infrastructure(compose_file, skip_validation=True) + + # Validation should not be called + mock_validate.assert_not_called() + # Docker compose should be called + mock_run.assert_called_once()