diff --git a/stackbox/cli/__main__.py b/stackbox/cli/__main__.py index e08078e..72663b8 100644 --- a/stackbox/cli/__main__.py +++ b/stackbox/cli/__main__.py @@ -230,10 +230,10 @@ def init( click.echo(f"\nāŒ Failed to build Ironic image: {e}", err=True) sys.exit(1) - # Phase 2: Start Ironic services - click.echo("\nšŸš€ Starting Ironic services...") + # Phase 2: Generate configuration + click.echo("\nāš™ļø Generating configuration...") try: - from stackbox.core import compose, config + from stackbox.core import compose, config, migrations # Create config directory config_dir_path = Path(config_dir).expanduser() @@ -254,22 +254,76 @@ def init( }, ) - # Start infrastructure and Ironic services - click.echo(" Starting containers...") - compose.start_infrastructure(compose_file) + click.echo("āœ… Configuration generated") + + except Exception as e: + click.echo(f"\nāŒ Failed to generate configuration: {e}", err=True) + sys.exit(1) + + # Phase 3: Start infrastructure services (MariaDB, RabbitMQ, etc.) + click.echo("\n🐳 Starting infrastructure services...") + try: + # Start only infrastructure services (NOT ironic services yet) + compose.start_infrastructure( + compose_file, + services=["mariadb", "rabbitmq", "libvirt", "sushy-tools"], + ) + click.echo("āœ… Infrastructure services started") + + except Exception as e: + click.echo(f"\nāŒ Failed to start infrastructure: {e}", err=True) + sys.exit(1) + + # Phase 4: Run database migrations + click.echo("\nšŸ—„ļø Running database migrations...") + try: + # Wait for database to be ready + migrations.wait_for_database( + config_dir_path, + ironic_image="stackbox-ironic:latest", + timeout=60, + ) + + # Run migrations + migrations.run_ironic_migrations( + config_dir_path, + ironic_image="stackbox-ironic:latest", + verbose=verbose, + ) + + # Verify migrations + migrations.verify_migrations( + config_dir_path, + ironic_image="stackbox-ironic:latest", + ) + + click.echo("āœ… Database initialized") + + except Exception as e: + click.echo(f"\nāŒ Migration error: {e}", err=True) + sys.exit(1) + + # Phase 5: Start Ironic services + click.echo("\nšŸš€ Starting Ironic services...") + try: + # Start ironic-api and ironic-conductor + compose.start_infrastructure( + compose_file, + services=["ironic-api", "ironic-conductor"], + ) - # Wait for services to become healthy + # Wait for all services to become healthy compose.wait_for_healthy(compose_file) click.echo("āœ… Ironic services started successfully") click.echo(" API endpoint: http://localhost:6385") except Exception as e: - click.echo(f"\nāŒ Failed to start services: {e}", err=True) + click.echo(f"\nāŒ Failed to start Ironic services: {e}", err=True) sys.exit(1) - # TODO: Phase 3 (Issue #7): Run migrations - # TODO: Phase 4 (Issue #11): Configure Tempest + # TODO: Phase 6 (Issue #8): Create virtual node + # TODO: Phase 7 (Issue #11): Configure Tempest click.echo("\nāœ… Initialization complete!") diff --git a/stackbox/core/compose.py b/stackbox/core/compose.py index 44c5673..c217dcd 100644 --- a/stackbox/core/compose.py +++ b/stackbox/core/compose.py @@ -145,13 +145,18 @@ def validate_environment() -> ValidationResult: } -def start_infrastructure(compose_file: Path, skip_validation: bool = False) -> None: +def start_infrastructure( + compose_file: Path, + skip_validation: bool = False, + services: list[str] | None = None, +) -> 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) + services: List of specific services to start (None = all services) Raises: RuntimeError: If Docker daemon is not running or if compose file doesn't exist @@ -160,8 +165,8 @@ def start_infrastructure(compose_file: Path, skip_validation: bool = False) -> N if not compose_file.exists(): raise RuntimeError(f"Compose file not found: {compose_file}") - # Run pre-flight checks - if not skip_validation: + # Run pre-flight checks (only if starting all services) + if not skip_validation and services is None: validation = validate_environment() if not validation["docker_running"]: @@ -188,8 +193,14 @@ def start_infrastructure(compose_file: Path, skip_validation: bool = False) -> N # Start services try: + cmd = ["docker-compose", "-f", str(compose_file), "up", "-d"] + + # Add specific services if provided + if services: + cmd.extend(services) + subprocess.run( - ["docker-compose", "-f", str(compose_file), "up", "-d"], + cmd, check=True, capture_output=True, text=True, diff --git a/stackbox/core/migrations.py b/stackbox/core/migrations.py new file mode 100644 index 0000000..b5cf60a --- /dev/null +++ b/stackbox/core/migrations.py @@ -0,0 +1,225 @@ +"""Database migration utilities for StackBox.""" + +from pathlib import Path +import subprocess +import time + +import click + + +def wait_for_database( + config_dir: Path, + ironic_image: str = "stackbox-ironic:latest", + timeout: int = 60, +) -> None: + """Wait for database to be ready to accept connections. + + Args: + config_dir: Directory containing config subdirectory with ironic.conf + ironic_image: Docker image to use for database connectivity test + timeout: Maximum time to wait in seconds + + Raises: + FileNotFoundError: If config file doesn't exist + RuntimeError: If database not ready within timeout + """ + config_file = config_dir / "config" / "ironic.conf" + if not config_file.exists(): + raise FileNotFoundError(f"Config file not found: {config_file}") + + click.echo("Waiting for database to be ready...") + + start = time.time() + while time.time() - start < timeout: + # Test database connection using oslo.db + cmd = [ + "docker", + "run", + "--rm", + "--network", + "stackbox-network", + "-v", + f"{config_file.parent}:/etc/ironic:ro", + ironic_image, + "python3", + "-c", + ( + "from oslo_db.sqlalchemy import enginefacade; " + "from oslo_config import cfg; " + "CONF = cfg.CONF; " + "CONF(['--config-file', '/etc/ironic/ironic.conf']); " + "enginefacade.get_legacy_facade().get_engine().connect()" + ), + ] + + result = subprocess.run( + cmd, + capture_output=True, + text=True, + check=False, + timeout=10, + ) + + if result.returncode == 0: + click.echo("āœ… Database is ready") + return + + # Check for permission errors (fail fast instead of timing out) + if result.stderr and ( + "Access denied" in result.stderr or "Permission denied" in result.stderr + ): + raise RuntimeError( + "Database permission error. Check credentials in ironic.conf\n" + "Verify with: docker exec stackbox-mariadb mysql -uironic -pstackbox-secret ironic -e 'SHOW TABLES;'\n" + f"Details: {result.stderr}" + ) + + time.sleep(2) + + raise RuntimeError( + f"Database not ready after {timeout}s. " f"Check: docker logs stackbox-mariadb" + ) + + +def run_ironic_migrations( + config_dir: Path, + ironic_image: str = "stackbox-ironic:latest", + verbose: bool = False, +) -> None: + """Run Ironic database migrations using ironic-dbsync. + + Args: + config_dir: Directory containing config subdirectory with ironic.conf + ironic_image: Docker image to use + verbose: Show verbose migration output + + Raises: + FileNotFoundError: If config file doesn't exist + RuntimeError: If migrations fail + """ + config_file = config_dir / "config" / "ironic.conf" + if not config_file.exists(): + raise FileNotFoundError(f"Config file not found: {config_file}") + + click.echo("Running database migrations...") + + # Run ironic-dbsync in a temporary container + cmd = [ + "docker", + "run", + "--rm", + "--network", + "stackbox-network", + "-v", + f"{config_file.parent}:/etc/ironic:ro", + ironic_image, + "ironic-dbsync", + "--config-file", + "/etc/ironic/ironic.conf", + "upgrade", + ] + + try: + result = subprocess.run( + cmd, + capture_output=not verbose, + text=True, + check=False, + timeout=120, # 2 minute timeout + ) + + if result.returncode == 0: + click.echo("āœ… Database migrations completed successfully") + else: + # Check for specific error patterns + stderr = result.stderr if result.stderr else "" + + # Permission errors + if "Access denied" in stderr or "Permission denied" in stderr: + raise RuntimeError( + "Database permission error. Check credentials in ironic.conf\n" + "Verify with: docker exec stackbox-mariadb mysql -uironic -pstackbox-secret ironic -e 'SHOW TABLES;'\n" + f"Details: {stderr}" + ) + + # Version mismatch errors + if ( + "Revision" in stderr + or "version mismatch" in stderr.lower() + or "alembic" in stderr.lower() + ): + raise RuntimeError( + "Database version mismatch detected.\n" + "Reset database with:\n" + " docker-compose -f .stackbox/docker-compose.yml down -v\n" + " sb init \n" + f"Details: {stderr}" + ) + + # Generic error + error_msg = "Database migrations failed" + if not verbose and stderr: + error_msg += f"\n{stderr}" + raise RuntimeError(error_msg) + + except subprocess.TimeoutExpired as e: + raise RuntimeError("Database migrations timed out after 120s") from e + + +def verify_migrations( + config_dir: Path, + ironic_image: str = "stackbox-ironic:latest", +) -> str: + """Verify database migrations were successful by checking schema version. + + Args: + config_dir: Directory containing config subdirectory with ironic.conf + ironic_image: Docker image to use + + Returns: + Database schema version string + + Raises: + FileNotFoundError: If config file doesn't exist + RuntimeError: If verification fails + """ + config_file = config_dir / "config" / "ironic.conf" + if not config_file.exists(): + raise FileNotFoundError(f"Config file not found: {config_file}") + + click.echo("Verifying database schema...") + + # Check database version + cmd = [ + "docker", + "run", + "--rm", + "--network", + "stackbox-network", + "-v", + f"{config_file.parent}:/etc/ironic:ro", + ironic_image, + "ironic-dbsync", + "--config-file", + "/etc/ironic/ironic.conf", + "version", + ] + + try: + result = subprocess.run( + cmd, + capture_output=True, + text=True, + check=False, + timeout=30, + ) + + if result.returncode == 0: + version = result.stdout.strip() + click.echo(f"āœ… Database schema version: {version}") + return version + else: + raise RuntimeError(f"Failed to verify database schema: {result.stderr}") + + except subprocess.TimeoutExpired as e: + raise RuntimeError("Verification timed out after 30s") from e diff --git a/tests/unit/core/test_compose.py b/tests/unit/core/test_compose.py index 0bd1ee8..251043d 100644 --- a/tests/unit/core/test_compose.py +++ b/tests/unit/core/test_compose.py @@ -196,6 +196,30 @@ def test_start_handles_port_conflict(self, mock_run: MagicMock, tmp_path: Path) with pytest.raises(RuntimeError, match="Port conflict detected"): start_infrastructure(compose_file, skip_validation=True) + @patch("subprocess.run") + def test_start_with_specific_services(self, mock_run: MagicMock, tmp_path: Path) -> None: + """Test that specific services can be started.""" + compose_file = tmp_path / "docker-compose.yml" + compose_file.write_text("version: '3.8'") + + # Start only specific services + start_infrastructure(compose_file, skip_validation=True, services=["mariadb", "rabbitmq"]) + + mock_run.assert_called_once_with( + [ + "docker-compose", + "-f", + str(compose_file), + "up", + "-d", + "mariadb", + "rabbitmq", + ], + check=True, + capture_output=True, + text=True, + ) + class TestStopInfrastructure: """Tests for stop_infrastructure function.""" diff --git a/tests/unit/core/test_migrations.py b/tests/unit/core/test_migrations.py new file mode 100644 index 0000000..4ac24ee --- /dev/null +++ b/tests/unit/core/test_migrations.py @@ -0,0 +1,241 @@ +"""Tests for stackbox.core.migrations module.""" + +from pathlib import Path +import subprocess +from unittest.mock import MagicMock, patch + +import pytest + +from stackbox.core.migrations import ( + run_ironic_migrations, + verify_migrations, + wait_for_database, +) + + +class TestWaitForDatabase: + """Tests for wait_for_database function.""" + + @patch("subprocess.run") + @patch("click.echo") + @patch("time.sleep") + def test_wait_succeeds_when_database_ready( + self, + mock_sleep: MagicMock, + mock_echo: MagicMock, + mock_run: MagicMock, + tmp_path: Path, + ) -> None: + """Test that function returns when database accepts connections.""" + config_dir = tmp_path + config_file = config_dir / "config" / "ironic.conf" + config_file.parent.mkdir(parents=True) + config_file.write_text("[database]\nconnection = mysql://test") + + # Database ready on first try + mock_run.return_value = MagicMock(returncode=0) + + wait_for_database(config_dir) + + # Should have called docker run with oslo.db test + assert mock_run.called + call_args = mock_run.call_args[0][0] + assert "docker" in call_args + assert "run" in call_args + assert "--network" in call_args + assert "stackbox-network" in call_args + assert "oslo_db" in " ".join(call_args) + + @patch("subprocess.run") + @patch("time.time") + @patch("time.sleep") + def test_wait_times_out_if_database_not_ready( + self, + mock_sleep: MagicMock, + mock_time: MagicMock, + mock_run: MagicMock, + tmp_path: Path, + ) -> None: + """Test that RuntimeError is raised on timeout.""" + config_dir = tmp_path + config_file = config_dir / "config" / "ironic.conf" + config_file.parent.mkdir(parents=True) + config_file.write_text("[database]\nconnection = mysql://test") + + # Simulate timeout + mock_time.side_effect = [0, 70] # Exceeds 60s timeout + mock_run.return_value = MagicMock(returncode=1) # Database not ready + + with pytest.raises(RuntimeError, match="Database not ready"): + wait_for_database(config_dir, timeout=60) + + def test_wait_raises_if_config_missing(self, tmp_path: Path) -> None: + """Test that FileNotFoundError is raised if config doesn't exist.""" + config_dir = tmp_path + + with pytest.raises(FileNotFoundError, match="Config file not found"): + wait_for_database(config_dir) + + @patch("subprocess.run") + @patch("time.sleep") + def test_wait_detects_permission_errors( + self, mock_sleep: MagicMock, mock_run: MagicMock, tmp_path: Path + ) -> None: + """Test that permission errors are detected and reported clearly.""" + config_dir = tmp_path + config_file = config_dir / "config" / "ironic.conf" + config_file.parent.mkdir(parents=True) + config_file.write_text("[database]\nconnection = mysql://test") + + # Simulate permission error + mock_run.return_value = MagicMock( + returncode=1, stderr="Access denied for user 'ironic'@'%'" + ) + + with pytest.raises(RuntimeError, match="Database permission error"): + wait_for_database(config_dir) + + +class TestRunIronicMigrations: + """Tests for run_ironic_migrations function.""" + + @patch("subprocess.run") + @patch("click.echo") + def test_migrations_run_successfully( + self, mock_echo: MagicMock, mock_run: MagicMock, tmp_path: Path + ) -> None: + """Test successful migration execution.""" + config_dir = tmp_path + config_file = config_dir / "config" / "ironic.conf" + config_file.parent.mkdir(parents=True) + config_file.write_text("[database]\nconnection = mysql://test") + + # Mock successful migration + mock_run.return_value = MagicMock(returncode=0) + + run_ironic_migrations(config_dir) + + # Should have called docker run with ironic-dbsync upgrade + assert mock_run.called + call_args = mock_run.call_args[0][0] + assert "docker" in call_args + assert "ironic-dbsync" in call_args + assert "upgrade" in call_args + assert "--network" in call_args + assert "stackbox-network" in call_args + + @patch("subprocess.run") + def test_migrations_raise_on_failure(self, mock_run: MagicMock, tmp_path: Path) -> None: + """Test that RuntimeError is raised if migrations fail.""" + config_dir = tmp_path + config_file = config_dir / "config" / "ironic.conf" + config_file.parent.mkdir(parents=True) + config_file.write_text("[database]\nconnection = mysql://test") + + # Mock failed migration + mock_run.return_value = MagicMock(returncode=1, stderr="Migration error") + + with pytest.raises(RuntimeError, match="Database migrations failed"): + run_ironic_migrations(config_dir) + + @patch("subprocess.run") + def test_migrations_raise_on_timeout(self, mock_run: MagicMock, tmp_path: Path) -> None: + """Test that RuntimeError is raised on timeout.""" + config_dir = tmp_path + config_file = config_dir / "config" / "ironic.conf" + config_file.parent.mkdir(parents=True) + config_file.write_text("[database]\nconnection = mysql://test") + + # Mock timeout + mock_run.side_effect = subprocess.TimeoutExpired("cmd", 120) + + with pytest.raises(RuntimeError, match="timed out"): + run_ironic_migrations(config_dir) + + def test_migrations_raise_if_config_missing(self, tmp_path: Path) -> None: + """Test that FileNotFoundError is raised if config doesn't exist.""" + config_dir = tmp_path + + with pytest.raises(FileNotFoundError, match="Config file not found"): + run_ironic_migrations(config_dir) + + @patch("subprocess.run") + def test_migrations_detect_permission_errors(self, mock_run: MagicMock, tmp_path: Path) -> None: + """Test that permission errors are detected and reported clearly.""" + config_dir = tmp_path + config_file = config_dir / "config" / "ironic.conf" + config_file.parent.mkdir(parents=True) + config_file.write_text("[database]\nconnection = mysql://test") + + # Simulate permission error + mock_run.return_value = MagicMock( + returncode=1, stderr="Access denied for user 'ironic'@'localhost'" + ) + + with pytest.raises(RuntimeError, match="Database permission error"): + run_ironic_migrations(config_dir) + + @patch("subprocess.run") + def test_migrations_detect_version_mismatch(self, mock_run: MagicMock, tmp_path: Path) -> None: + """Test that version mismatch errors are detected and reported clearly.""" + config_dir = tmp_path + config_file = config_dir / "config" / "ironic.conf" + config_file.parent.mkdir(parents=True) + config_file.write_text("[database]\nconnection = mysql://test") + + # Simulate version mismatch error + mock_run.return_value = MagicMock( + returncode=1, + stderr="alembic.util.exc.CommandError: Target database is not up to date. Revision abc123 expected", + ) + + with pytest.raises(RuntimeError, match="Database version mismatch"): + run_ironic_migrations(config_dir) + + +class TestVerifyMigrations: + """Tests for verify_migrations function.""" + + @patch("subprocess.run") + @patch("click.echo") + def test_verification_returns_version( + self, mock_echo: MagicMock, mock_run: MagicMock, tmp_path: Path + ) -> None: + """Test that verification returns database version.""" + config_dir = tmp_path + config_file = config_dir / "config" / "ironic.conf" + config_file.parent.mkdir(parents=True) + config_file.write_text("[database]\nconnection = mysql://test") + + # Mock successful verification + mock_run.return_value = MagicMock(returncode=0, stdout="abc123def456\n") + + version = verify_migrations(config_dir) + + assert version == "abc123def456" + + # Should have called ironic-dbsync version + call_args = mock_run.call_args[0][0] + assert "ironic-dbsync" in call_args + assert "version" in call_args + + @patch("subprocess.run") + def test_verification_raises_on_failure(self, mock_run: MagicMock, tmp_path: Path) -> None: + """Test that RuntimeError is raised if verification fails.""" + config_dir = tmp_path + config_file = config_dir / "config" / "ironic.conf" + config_file.parent.mkdir(parents=True) + config_file.write_text("[database]\nconnection = mysql://test") + + # Mock failed verification + mock_run.return_value = MagicMock(returncode=1, stderr="Version check failed") + + with pytest.raises(RuntimeError, match="Failed to verify database schema"): + verify_migrations(config_dir) + + def test_verification_raises_if_config_missing(self, tmp_path: Path) -> None: + """Test that FileNotFoundError is raised if config doesn't exist.""" + config_dir = tmp_path + + with pytest.raises(FileNotFoundError, match="Config file not found"): + verify_migrations(config_dir)