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
62 changes: 55 additions & 7 deletions stackbox/cli/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,15 @@ def init(
debug=verbose,
)

# Copy libvirt Dockerfile
click.echo(" Copying libvirt Dockerfile...")
libvirt_dockerfile_src = (
Path(__file__).parent.parent / "templates" / "libvirt" / "Dockerfile"
)
libvirt_dockerfile_dest = config_dir_path / "config" / "libvirt" / "Dockerfile"
libvirt_dockerfile_dest.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(libvirt_dockerfile_src, libvirt_dockerfile_dest)

# Generate docker-compose.yml
click.echo(" Generating docker-compose.yml...")
compose_file = config_dir_path / "docker-compose.yml"
Expand All @@ -273,6 +282,39 @@ def init(
click.echo(f"\n❌ Failed to generate configuration: {e}", err=True)
sys.exit(1)

# Phase 2.5: Build libvirt image
click.echo("\n🐳 Building libvirt image...")
try:
from stackbox.core.container import get_container_runtime

runtime_cmd, _ = get_container_runtime()

# Build libvirt image
click.echo(" Building libvirt container (this may take a few minutes)...")
result = subprocess.run(
[
runtime_cmd,
"build",
"-t",
"stackbox-libvirt:latest",
"-f",
str(libvirt_dockerfile_dest),
str(config_dir_path),
],
capture_output=True,
text=True,
timeout=300, # 5 minutes should be enough
)

if result.returncode != 0:
raise RuntimeError(f"Libvirt image build failed: {result.stderr}")

click.echo("✅ Libvirt image built successfully")

except Exception as e:
click.echo(f"\n❌ Failed to build libvirt image: {e}", err=True)
sys.exit(1)

# Phase 3: Start infrastructure services (MariaDB, RabbitMQ, etc.)
click.echo("\n🐳 Starting infrastructure services...")
try:
Expand Down Expand Up @@ -496,12 +538,12 @@ def init(
driver="redfish",
deploy_interface="direct",
)
click.echo(f" ✅ Generated: {tempest_conf.relative_to(Path.cwd())}")
click.echo(f" ✅ Generated: {tempest_conf.relative_to(config_dir_path)}")

# Generate accounts.yaml
accounts_file = config_dir_path / "config" / "tempest" / "accounts.yaml"
config_gen.generate_tempest_accounts(output_path=accounts_file)
click.echo(f" ✅ Generated: {accounts_file.relative_to(Path.cwd())}")
click.echo(f" ✅ Generated: {accounts_file.relative_to(config_dir_path)}")

# Validate configuration
if config_gen.validate_tempest_config(tempest_conf):
Expand All @@ -523,20 +565,26 @@ def init(
tempest_dockerfile_dest = config_dir_path / "config" / "tempest" / "Dockerfile"

shutil.copy2(tempest_dockerfile_src, tempest_dockerfile_dest)
click.echo(f" ✅ Copied: {tempest_dockerfile_dest.relative_to(Path.cwd())}")
click.echo(f" ✅ Copied: {tempest_dockerfile_dest.relative_to(config_dir_path)}")

# Build Tempest image
click.echo(" 🔨 Building Tempest image (this may take a few minutes)...")

from stackbox.core.container import get_compose_command

compose_file = config_dir_path / "docker-compose.yml"
cmd = get_compose_command(str(compose_file))
cmd.extend(["--profile", "testing", "build", "tempest"])

result = subprocess.run(
["docker-compose", "build", "tempest"],
cwd=config_dir_path,
cmd,
capture_output=True,
text=True,
timeout=600, # 10 minutes for image build
)

if result.returncode != 0:
raise RuntimeError(f"Docker build failed: {result.stderr}")
raise RuntimeError(f"Build failed: {result.stderr}")

click.echo(" ✅ Tempest image built successfully")

Expand All @@ -549,7 +597,7 @@ def init(
click.echo(f" Virtual node: {node_name}")
click.echo(" BMC endpoint: http://localhost:8000/redfish/v1/")
click.echo(f" Enrolled in Ironic: {enrolled_node['uuid']}")
click.echo(f" Tempest config: {tempest_conf.relative_to(Path.cwd())}")
click.echo(f" Tempest config: {tempest_conf.relative_to(config_dir_path)}")
click.echo("\n💡 Ready to run tests:")
click.echo(" sb test # Run all Tempest tests")

Expand Down
68 changes: 43 additions & 25 deletions stackbox/core/builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

import os
from pathlib import Path
import shutil
import subprocess
import time

Expand Down Expand Up @@ -54,6 +55,17 @@ def build_ironic_image(
if not dockerfile_path.exists():
raise RuntimeError(f"Dockerfile not found: {dockerfile_path}")

# Copy healthcheck.sh to build context (required by Dockerfile COPY)
healthcheck_src = Path(__file__).parent.parent / "templates" / "ironic" / "healthcheck.sh"
healthcheck_dest = ironic_source_path / "healthcheck.sh"

# Track if we need to clean up healthcheck.sh after build
cleanup_healthcheck = False

if healthcheck_src.exists():
shutil.copy2(healthcheck_src, healthcheck_dest)
cleanup_healthcheck = True

click.echo(f"Building Ironic image from {ironic_source_path}")
click.echo(f"Using Dockerfile: {dockerfile_path}")

Expand Down Expand Up @@ -86,31 +98,37 @@ def build_ironic_image(
start_time = time.time()

try:
result = subprocess.run(
cmd,
env=env,
capture_output=not verbose,
text=True,
check=False,
timeout=timeout,
)

elapsed = time.time() - start_time

if result.returncode != 0:
click.echo("\n❌ Build failed!", err=True)
if not verbose and result.stderr:
click.echo(result.stderr, err=True)
raise RuntimeError(f"Docker build failed with exit code {result.returncode}")

click.echo(f"\n✅ Build completed in {elapsed:.1f}s")

return elapsed

except FileNotFoundError as e:
raise RuntimeError("Docker command not found. Is Docker installed and in PATH?") from e
except subprocess.TimeoutExpired as e:
raise RuntimeError(f"Build timeout after {timeout}s") from e
try:
result = subprocess.run(
cmd,
env=env,
capture_output=not verbose,
text=True,
check=False,
timeout=timeout,
)

elapsed = time.time() - start_time

if result.returncode != 0:
click.echo("\n❌ Build failed!", err=True)
if not verbose and result.stderr:
click.echo(result.stderr, err=True)
raise RuntimeError(f"Docker build failed with exit code {result.returncode}")

click.echo(f"\n✅ Build completed in {elapsed:.1f}s")

return elapsed

except FileNotFoundError as e:
raise RuntimeError("Docker command not found. Is Docker installed and in PATH?") from e
except subprocess.TimeoutExpired as e:
raise RuntimeError(f"Build timeout after {timeout}s") from e

finally:
# Clean up healthcheck.sh if we copied it
if cleanup_healthcheck and healthcheck_dest.exists():
healthcheck_dest.unlink()


def validate_image(tag: str = "stackbox-ironic:latest") -> bool:
Expand Down
112 changes: 92 additions & 20 deletions stackbox/core/compose.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
from jinja2 import Template
import yaml

from stackbox.core.container import get_compose_command, get_container_runtime


class ValidationResult(TypedDict):
"""Result from validate_environment()."""
Expand Down Expand Up @@ -193,18 +195,60 @@ def start_infrastructure(

# Start services
try:
cmd = ["docker-compose", "-f", str(compose_file), "up", "-d"]

# Add specific services if provided
if services:
cmd.extend(services)

subprocess.run(
cmd,
check=True,
capture_output=True,
text=True,
)
_, compose_cmd = get_container_runtime()

# Workaround: podman-compose hangs when starting multiple services at once
# Start them one by one if using podman-compose
if "podman-compose" in compose_cmd and services and len(services) > 1:
for service in services:
cmd = [compose_cmd, "-f", str(compose_file), "up", "-d", service]
result = subprocess.run(
cmd,
check=False, # Don't raise on non-zero exit
capture_output=True,
text=True,
timeout=60, # Reduced timeout - container creation should be fast
)
# Check if container was created even if compose command hung
# podman-compose often exits with timeout but container is created
if result.returncode != 0:
# Check if service is actually running
runtime_cmd, _ = get_container_runtime()
check_result = subprocess.run(
[
runtime_cmd,
"ps",
"-a",
"--filter",
f"name={service}",
"--format",
"{{.Status}}",
],
capture_output=True,
text=True,
check=False,
)
if "Created" in check_result.stdout or "Up" in check_result.stdout:
# Container exists, manually start it
subprocess.run(
[runtime_cmd, "start", service], check=False, capture_output=True
)
elif result.stderr and "port is already allocated" not in result.stderr:
# Real error, not just a timeout
raise RuntimeError(f"Failed to start {service}: {result.stderr}")
else:
cmd = [compose_cmd, "-f", str(compose_file), "up", "-d"]

# Add specific services if provided
if services:
cmd.extend(services)

subprocess.run(
cmd,
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:
Expand Down Expand Up @@ -236,7 +280,8 @@ def stop_infrastructure(compose_file: Path, remove_volumes: bool = False) -> Non
# Gracefully handle missing file (already cleaned up)
return

cmd = ["docker-compose", "-f", str(compose_file), "down"]
_, compose_cmd = get_container_runtime()
cmd = [compose_cmd, "-f", str(compose_file), "down"]
if remove_volumes:
cmd.append("-v")

Expand All @@ -261,8 +306,10 @@ def wait_for_healthy(compose_file: Path, timeout: int = 120) -> None:

while time.time() - start_time < timeout:
# Get service status
cmd = get_compose_command(str(compose_file))
cmd.extend(["ps", "--format", "json"])
result = subprocess.run(
["docker", "compose", "-f", str(compose_file), "ps", "--format", "json"],
cmd,
capture_output=True,
text=True,
check=False,
Expand All @@ -272,18 +319,43 @@ def wait_for_healthy(compose_file: Path, timeout: int = 120) -> None:
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))
# podman-compose returns JSON array: [{"Name": ...}, ...]
# docker-compose v2 returns JSONL: {"Name": ...}\n{"Name": ...}
output = result.stdout.strip()
if not output:
services = []
elif output.startswith("["):
# JSON array format (podman-compose)
services = json.loads(output)
else:
# JSONL format (docker-compose v2)
services = []
for line in output.split("\n"):
if line:
services.append(json.loads(line))

# Check if all services are healthy
unhealthy = []
for service in services:
# Get service name (podman-compose uses "Names" list, docker-compose uses "Name" string)
name = service.get("Name") or (service.get("Names", ["unknown"])[0])

# Get health status
# Docker Compose v2: "Health" field
# podman-compose: parse from "Status" field like "Up 3 minutes (healthy)"
health = service.get("Health", "")
if not health:
status = service.get("Status", "")
if "(healthy)" in status:
health = "healthy"
elif "(starting)" in status or "Starting" in status:
health = "starting"
elif "(unhealthy)" in status:
health = "unhealthy"

# Check if service is running but not yet healthy
if health != "healthy" and service.get("State") == "running":
# Service is running but not yet healthy
unhealthy.append(service["Name"])
unhealthy.append(name)

if not unhealthy:
click.echo("✅ All services are healthy!")
Expand Down
4 changes: 2 additions & 2 deletions stackbox/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,8 @@ def __init__(self, template_dir: Path | None = None) -> None:
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/",
database_url: str = "mysql+pymysql://ironic:stackbox-secret@mariadb/ironic?charset=utf8",
rabbitmq_url: str = "rabbit://stackrabbit:stackbox-secret@rabbitmq:5672/",
api_host: str = "0.0.0.0",
api_port: int = 6385,
) -> None:
Expand Down
Loading
Loading