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
39 changes: 38 additions & 1 deletion stackbox/cli/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
72 changes: 65 additions & 7 deletions stackbox/core/compose.py
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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
Expand All @@ -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"
Expand All @@ -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:
Expand Down Expand Up @@ -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."
)
53 changes: 53 additions & 0 deletions stackbox/core/config.py
Original file line number Diff line number Diff line change
@@ -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)
58 changes: 58 additions & 0 deletions stackbox/templates/docker-compose.yml.j2
Original file line number Diff line number Diff line change
Expand Up @@ -84,13 +84,71 @@ 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
libvirt-data:
name: stackbox-libvirt-data
libvirt-images:
name: stackbox-libvirt-images
ironic-lib:
name: stackbox-ironic-lib
ironic-log:
name: stackbox-ironic-log

networks:
stackbox:
Expand Down
52 changes: 52 additions & 0 deletions stackbox/templates/ironic/ironic.conf.j2
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading