diff --git a/stackbox/cli/__main__.py b/stackbox/cli/__main__.py index fcf002d..e08078e 100644 --- a/stackbox/cli/__main__.py +++ b/stackbox/cli/__main__.py @@ -230,7 +230,44 @@ def init( click.echo(f"\nāŒ Failed to build Ironic image: {e}", err=True) sys.exit(1) - # TODO: Phase 2 (Issue #6): Start containers + # Phase 2: Start Ironic services + click.echo("\nšŸš€ Starting Ironic services...") + try: + from stackbox.core import compose, config + + # Create config directory + config_dir_path = Path(config_dir).expanduser() + config_dir_path.mkdir(parents=True, exist_ok=True) + + # Generate ironic.conf + click.echo(" Generating ironic.conf...") + config_gen = config.ConfigGenerator() + config_gen.generate_ironic_conf(output_path=config_dir_path / "config" / "ironic.conf") + + # Generate docker-compose.yml + click.echo(" Generating docker-compose.yml...") + compose_file = config_dir_path / "docker-compose.yml" + compose.generate_compose_file( + output_path=compose_file, + template_vars={ + "ironic_image": "stackbox-ironic:latest", + }, + ) + + # Start infrastructure and Ironic services + click.echo(" Starting containers...") + compose.start_infrastructure(compose_file) + + # Wait for 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) + sys.exit(1) + # TODO: Phase 3 (Issue #7): Run migrations # TODO: Phase 4 (Issue #11): Configure Tempest diff --git a/stackbox/core/compose.py b/stackbox/core/compose.py index 25de69b..44c5673 100644 --- a/stackbox/core/compose.py +++ b/stackbox/core/compose.py @@ -1,10 +1,13 @@ """Docker Compose infrastructure management for StackBox.""" import contextlib +import json from pathlib import Path import subprocess +import time from typing import TypedDict +import click from jinja2 import Template import yaml @@ -18,15 +21,16 @@ class ValidationResult(TypedDict): kvm_available: bool -def generate_compose_file(output_path: Path, config: dict[str, str] | None = None) -> Path: +def generate_compose_file(output_path: Path, template_vars: 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: + template_vars: Template variables dictionary with optional keys: - db_password: MariaDB password (default: "stackbox-secret") - rabbit_password: RabbitMQ password (default: "stackbox-secret") + - ironic_image: Ironic Docker image tag (default: "stackbox-ironic:latest") Returns: Path to the generated docker-compose.yml file @@ -36,12 +40,12 @@ def generate_compose_file(output_path: Path, config: dict[str, str] | None = Non yaml.YAMLError: If generated content is invalid YAML PermissionError: If output_path is not writable """ - if config is None: - config = {} + if template_vars is None: + template_vars = {} # Set defaults - config.setdefault("db_password", "stackbox-secret") - config.setdefault("rabbit_password", "stackbox-secret") + template_vars.setdefault("db_password", "stackbox-secret") + template_vars.setdefault("rabbit_password", "stackbox-secret") # Locate template template_path = Path(__file__).parent.parent / "templates" / "docker-compose.yml.j2" @@ -51,7 +55,7 @@ def generate_compose_file(output_path: Path, config: dict[str, str] | None = Non # Render template template = Template(template_path.read_text()) - rendered = template.render(**config) + rendered = template.render(**template_vars) # Validate YAML (catch errors early) try: @@ -228,3 +232,57 @@ def stop_infrastructure(compose_file: Path, remove_volumes: bool = False) -> Non # Don't raise if services already stopped with contextlib.suppress(subprocess.CalledProcessError): subprocess.run(cmd, check=True, capture_output=True, text=True) + + +def wait_for_healthy(compose_file: Path, timeout: int = 120) -> None: + """Wait for all Docker Compose services to become healthy. + + Args: + compose_file: Path to docker-compose.yml + timeout: Maximum seconds to wait (default: 120) + + Raises: + RuntimeError: If services don't become healthy within timeout + """ + click.echo("Waiting for services to become healthy...") + + start_time = time.time() + + while time.time() - start_time < timeout: + # Get service status + result = subprocess.run( + ["docker", "compose", "-f", str(compose_file), "ps", "--format", "json"], + capture_output=True, + text=True, + check=False, + ) + + if result.returncode != 0: + raise RuntimeError(f"Failed to check service status: {result.stderr}") + + # Parse JSON output + services = [] + for line in result.stdout.strip().split("\n"): + if line: + services.append(json.loads(line)) + + # Check if all services are healthy + unhealthy = [] + for service in services: + health = service.get("Health", "") + if health != "healthy" and service.get("State") == "running": + # Service is running but not yet healthy + unhealthy.append(service["Name"]) + + if not unhealthy: + click.echo("āœ… All services are healthy!") + return + + # Still waiting + click.echo(f" Waiting for: {', '.join(unhealthy)}") + time.sleep(5) + + raise RuntimeError( + f"Services did not become healthy within {timeout}s. " + f"Run 'docker compose -f {compose_file} ps' to check status." + ) diff --git a/stackbox/core/config.py b/stackbox/core/config.py new file mode 100644 index 0000000..bf9d47d --- /dev/null +++ b/stackbox/core/config.py @@ -0,0 +1,53 @@ +"""Configuration file generator for StackBox services.""" + +from pathlib import Path + +from jinja2 import Environment, FileSystemLoader + + +class ConfigGenerator: + """Generates configuration files from Jinja2 templates.""" + + def __init__(self, template_dir: Path | None = None) -> None: + """Initialize config generator. + + Args: + template_dir: Directory containing Jinja2 templates + (defaults to stackbox/templates) + """ + if template_dir is None: + template_dir = Path(__file__).parent.parent / "templates" + + self.env = Environment( + loader=FileSystemLoader(str(template_dir)), + autoescape=False, # Config files don't need HTML escaping + ) + + def generate_ironic_conf( + self, + output_path: Path, + database_url: str = "mysql+pymysql://ironic:ironic@mariadb/ironic?charset=utf8", + rabbitmq_url: str = "rabbit://ironic:ironic@rabbitmq:5672/", + api_host: str = "0.0.0.0", + api_port: int = 6385, + ) -> None: + """Generate ironic.conf from template. + + Args: + output_path: Where to write ironic.conf + database_url: SQLAlchemy database connection URL + rabbitmq_url: RabbitMQ connection URL + api_host: API server bind address + api_port: API server port + """ + template = self.env.get_template("ironic/ironic.conf.j2") + + config_content = template.render( + database_url=database_url, + rabbitmq_url=rabbitmq_url, + api_host=api_host, + api_port=api_port, + ) + + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_text(config_content) diff --git a/stackbox/templates/docker-compose.yml.j2 b/stackbox/templates/docker-compose.yml.j2 index 4503a09..264e24e 100644 --- a/stackbox/templates/docker-compose.yml.j2 +++ b/stackbox/templates/docker-compose.yml.j2 @@ -84,6 +84,60 @@ services: retries: 5 restart: unless-stopped + ironic-api: + image: {{ ironic_image | default('stackbox-ironic:latest') }} + container_name: ironic-api + command: ironic-api + ports: + - "6385:6385" # Ironic API + - "8080:8080" # HTTP boot server + volumes: + - ./config/ironic.conf:/etc/ironic/ironic.conf:ro + - ironic-lib:/var/lib/ironic + - ironic-log:/var/log/ironic + depends_on: + mariadb: + condition: service_healthy + rabbitmq: + condition: service_healthy + networks: + - stackbox + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:6385/"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 30s + restart: unless-stopped + + ironic-conductor: + image: {{ ironic_image | default('stackbox-ironic:latest') }} + container_name: ironic-conductor + command: ironic-conductor + privileged: true # Required for hardware access + volumes: + - ./config/ironic.conf:/etc/ironic/ironic.conf:ro + - ironic-lib:/var/lib/ironic + - ironic-log:/var/log/ironic + - /dev:/dev # Hardware device access + - /sys:/sys # System information access + depends_on: + mariadb: + condition: service_healthy + rabbitmq: + condition: service_healthy + ironic-api: + condition: service_healthy + networks: + - stackbox + healthcheck: + test: ["CMD", "pgrep", "-f", "ironic-conductor"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 30s + restart: unless-stopped + volumes: mariadb-data: name: stackbox-mariadb-data @@ -91,6 +145,10 @@ volumes: name: stackbox-libvirt-data libvirt-images: name: stackbox-libvirt-images + ironic-lib: + name: stackbox-ironic-lib + ironic-log: + name: stackbox-ironic-log networks: stackbox: diff --git a/stackbox/templates/ironic/ironic.conf.j2 b/stackbox/templates/ironic/ironic.conf.j2 new file mode 100644 index 0000000..395fcd9 --- /dev/null +++ b/stackbox/templates/ironic/ironic.conf.j2 @@ -0,0 +1,52 @@ +[DEFAULT] +# Logging +debug = True +log_file = /var/log/ironic/ironic.log + +# RPC / Message Queue +transport_url = {{ rabbitmq_url }} + +# Authentication (noauth for development) +auth_strategy = noauth + +[api] +host = {{ api_host }} +port = {{ api_port }} + +[conductor] +automated_clean = False +enabled_hardware_types = ipmi,fake-hardware +enabled_boot_interfaces = pxe,fake +enabled_deploy_interfaces = direct,fake +enabled_inspect_interfaces = no-inspect +enabled_management_interfaces = ipmitool,fake +enabled_network_interfaces = flat,noop +enabled_power_interfaces = ipmitool,fake +enabled_raid_interfaces = no-raid +enabled_storage_interfaces = noop +enabled_vendor_interfaces = no-vendor + +[database] +connection = {{ database_url }} + +[deploy] +http_url = http://ironic-api:8080 +http_root = /var/lib/ironic/httpboot + +[pxe] +tftp_root = /var/lib/ironic/tftpboot +tftp_server = ironic-conductor + +[agent] +# Deploy agent configuration +deploy_logs_collect = always +deploy_logs_storage_backend = local +deploy_logs_local_path = /var/log/ironic/deploy + +[inspector] +# No inspector for basic setup +enabled = False + +[service_catalog] +# Service catalog for multi-service deployments +endpoint_override = http://ironic-api:6385 diff --git a/tests/unit/core/test_compose.py b/tests/unit/core/test_compose.py index 56fc962..0bd1ee8 100644 --- a/tests/unit/core/test_compose.py +++ b/tests/unit/core/test_compose.py @@ -31,12 +31,12 @@ def test_generate_with_defaults(self, tmp_path: Path) -> None: def test_generate_with_custom_config(self, tmp_path: Path) -> None: """Test that custom passwords are used.""" output = tmp_path / "docker-compose.yml" - config = { + template_vars = { "db_password": "custom-db-pass", "rabbit_password": "custom-rabbit-pass", } - generate_compose_file(output, config) + generate_compose_file(output, template_vars) content = output.read_text() assert "custom-db-pass" in content @@ -250,3 +250,106 @@ def test_stop_handles_already_stopped_services( # Should not raise stop_infrastructure(compose_file) + + +class TestWaitForHealthy: + """Tests for wait_for_healthy function.""" + + @patch("subprocess.run") + @patch("click.echo") + @patch("time.sleep") + def test_wait_returns_when_all_healthy( + self, mock_sleep: MagicMock, mock_echo: MagicMock, mock_run: MagicMock, tmp_path: Path + ) -> None: + """Test that wait_for_healthy returns when all services are healthy.""" + from stackbox.core.compose import wait_for_healthy + + compose_file = tmp_path / "docker-compose.yml" + + # Mock response: all services healthy + mock_run.return_value = MagicMock( + returncode=0, + stdout='{"Name": "ironic-api", "State": "running", "Health": "healthy"}\n' + '{"Name": "mariadb", "State": "running", "Health": "healthy"}\n', + ) + + # Should return without raising + wait_for_healthy(compose_file) + + # Should have echoed success message + echo_calls = [str(call) for call in mock_echo.call_args_list] + assert any("All services are healthy" in call for call in echo_calls) + + @patch("subprocess.run") + @patch("click.echo") + @patch("time.time") + @patch("time.sleep") + def test_wait_times_out_if_not_healthy( + self, + mock_sleep: MagicMock, + mock_time: MagicMock, + mock_echo: MagicMock, + mock_run: MagicMock, + tmp_path: Path, + ) -> None: + """Test that wait_for_healthy raises RuntimeError on timeout.""" + from stackbox.core.compose import wait_for_healthy + + compose_file = tmp_path / "docker-compose.yml" + + # Mock time to simulate timeout + mock_time.side_effect = [0, 130] # Exceeds 120s timeout + + # Mock response: service not healthy + mock_run.return_value = MagicMock( + returncode=0, + stdout='{"Name": "ironic-api", "State": "running", "Health": "starting"}\n', + ) + + with pytest.raises(RuntimeError, match="did not become healthy"): + wait_for_healthy(compose_file, timeout=120) + + @patch("subprocess.run") + @patch("click.echo") + @patch("time.sleep") + def test_wait_shows_progress_for_unhealthy_services( + self, mock_sleep: MagicMock, mock_echo: MagicMock, mock_run: MagicMock, tmp_path: Path + ) -> None: + """Test that wait_for_healthy shows which services are waiting.""" + from stackbox.core.compose import wait_for_healthy + + compose_file = tmp_path / "docker-compose.yml" + + # First call: one service still starting + # Second call: all healthy + mock_run.side_effect = [ + MagicMock( + returncode=0, + stdout='{"Name": "ironic-api", "State": "running", "Health": "starting"}\n' + '{"Name": "mariadb", "State": "running", "Health": "healthy"}\n', + ), + MagicMock( + returncode=0, + stdout='{"Name": "ironic-api", "State": "running", "Health": "healthy"}\n' + '{"Name": "mariadb", "State": "running", "Health": "healthy"}\n', + ), + ] + + wait_for_healthy(compose_file) + + # Should have shown waiting message + echo_calls = [str(call) for call in mock_echo.call_args_list] + assert any("Waiting for: ironic-api" in call for call in echo_calls) + + @patch("subprocess.run") + def test_wait_raises_on_docker_error(self, mock_run: MagicMock, tmp_path: Path) -> None: + """Test that docker command failures are handled.""" + from stackbox.core.compose import wait_for_healthy + + compose_file = tmp_path / "docker-compose.yml" + + # Mock docker command failure + mock_run.return_value = MagicMock(returncode=1, stderr="Error: compose file not found") + + with pytest.raises(RuntimeError, match="Failed to check service status"): + wait_for_healthy(compose_file) diff --git a/tests/unit/core/test_config.py b/tests/unit/core/test_config.py new file mode 100644 index 0000000..705f586 --- /dev/null +++ b/tests/unit/core/test_config.py @@ -0,0 +1,75 @@ +"""Tests for stackbox.core.config module.""" + +from pathlib import Path + +from stackbox.core.config import ConfigGenerator + + +class TestConfigGenerator: + """Tests for ConfigGenerator class.""" + + def test_init_default_template_dir(self) -> None: + """Test that default template dir is auto-detected.""" + gen = ConfigGenerator() + + # Should point to stackbox/templates + assert "templates" in str(gen.env.loader.searchpath[0]) + + def test_init_custom_template_dir(self, tmp_path: Path) -> None: + """Test using custom template directory.""" + template_dir = tmp_path / "custom_templates" + template_dir.mkdir() + + gen = ConfigGenerator(template_dir=template_dir) + + assert str(template_dir) in gen.env.loader.searchpath[0] + + def test_generate_ironic_conf_with_defaults(self, tmp_path: Path) -> None: + """Test generating ironic.conf with default values.""" + gen = ConfigGenerator() + output_file = tmp_path / "ironic.conf" + + gen.generate_ironic_conf(output_path=output_file) + + # File should exist + assert output_file.exists() + + # Should contain default database URL + content = output_file.read_text() + assert "mysql+pymysql://ironic:ironic@mariadb/ironic" in content + assert "rabbit://ironic:ironic@rabbitmq:5672/" in content + assert "host = 0.0.0.0" in content + assert "port = 6385" in content + + def test_generate_ironic_conf_with_custom_values(self, tmp_path: Path) -> None: + """Test generating ironic.conf with custom values.""" + gen = ConfigGenerator() + output_file = tmp_path / "ironic.conf" + + gen.generate_ironic_conf( + output_path=output_file, + database_url="mysql://custom_user:pass@dbhost/dbname", + rabbitmq_url="rabbit://rmq_user:pass@rmqhost:5672/", + api_host="127.0.0.1", + api_port=9999, + ) + + content = output_file.read_text() + assert "mysql://custom_user:pass@dbhost/dbname" in content + assert "rabbit://rmq_user:pass@rmqhost:5672/" in content + assert "host = 127.0.0.1" in content + assert "port = 9999" in content + + def test_generate_creates_parent_directories(self, tmp_path: Path) -> None: + """Test that parent directories are created automatically.""" + gen = ConfigGenerator() + output_file = tmp_path / "nested" / "path" / "ironic.conf" + + # Parent directories don't exist yet + assert not output_file.parent.exists() + + gen.generate_ironic_conf(output_path=output_file) + + # Now they should exist + assert output_file.parent.exists() + assert output_file.exists()