Skip to content
Merged
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
74 changes: 64 additions & 10 deletions stackbox/cli/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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!")

Expand Down
19 changes: 15 additions & 4 deletions stackbox/core/compose.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"]:
Expand All @@ -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,
Expand Down
225 changes: 225 additions & 0 deletions stackbox/core/migrations.py
Original file line number Diff line number Diff line change
@@ -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 <job-name>\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
Loading
Loading