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
85 changes: 84 additions & 1 deletion stackbox/cli/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from pathlib import Path
import sys
import time

import click

Expand Down Expand Up @@ -244,6 +245,13 @@ def init(
config_gen = config.ConfigGenerator()
config_gen.generate_ironic_conf(output_path=config_dir_path / "config" / "ironic.conf")

# Generate sushy-emulator.conf (BMC configuration)
click.echo(" Generating sushy-emulator.conf...")
config_gen.generate_sushy_conf(
output_path=config_dir_path / "config" / "sushy" / "sushy-emulator.conf",
debug=verbose,
)

# Generate docker-compose.yml
click.echo(" Generating docker-compose.yml...")
compose_file = config_dir_path / "docker-compose.yml"
Expand Down Expand Up @@ -357,13 +365,88 @@ def init(
click.echo(f"\n❌ Failed to create VM: {e}", err=True)
sys.exit(1)

# TODO: Phase 7 (Issue #9): Configure BMC emulator
# Phase 7: Verify BMC can see VMs
click.echo("\n🔌 Verifying BMC configuration...")
try:
import requests

from stackbox.core import bmc

# Give sushy-tools a moment to discover VMs
time.sleep(5)

bmc_client = bmc.RedfishBMC("http://localhost:8000")
systems = bmc_client.get_systems()

click.echo(f"BMC discovered {len(systems)} system(s)")

# Detect TCP-specific issue: Empty systems list
if len(systems) == 0:
click.echo(
"\n⚠️ BMC is running but discovered 0 systems. "
"sushy-tools may not be able to connect to libvirt via TCP (qemu+tcp://libvirt:16509/system), "
"or no VMs are defined in libvirt yet. This may resolve after services stabilize.",
err=True,
)

# Verify node is visible
elif node_info["uuid"] in systems:
click.echo(f" ✅ {node_name} ({node_info['uuid'][:8]}...)")
else:
click.echo(f" ⚠️ {node_name} not found in BMC", err=True)
click.echo(" This may resolve after services stabilize")

except requests.ConnectionError as e:
# Scenario 1: sushy-tools container not running or unreachable
click.echo(
f"\n❌ Cannot connect to BMC service at http://localhost:8000: {e}\n"
"The sushy-tools container is not running, unreachable, or failed to start. "
"BMC must be working for node enrollment (Issue #10).",
err=True,
)

except requests.Timeout as e:
# Scenario 2: sushy-tools not responding
click.echo(
f"\n⚠️ BMC verification timeout: {e}\n"
"sushy-tools is not responding (may still be starting up). "
"This may resolve after services stabilize.",
err=True,
)

except requests.HTTPError as e:
# Scenario 3: HTTP error from sushy-tools (500, 404, etc.)
status_code = e.response.status_code if e.response else "unknown"

if status_code == 500:
click.echo(
f"\n❌ BMC returned HTTP {status_code} (Internal Server Error): {e}\n"
"sushy-tools encountered an internal error, possibly due to invalid configuration "
"or inability to connect to libvirt via TCP. BMC must be working for node enrollment.",
err=True,
)
else:
click.echo(
f"\n❌ BMC returned HTTP {status_code}: {e}\n"
"Unexpected error from sushy-tools Redfish API. BMC must be working for node enrollment.",
err=True,
)

except Exception as e:
# Scenario 4: Generic error (network issues, unexpected failures)
click.echo(
f"\n⚠️ BMC verification failed: {e}\n"
"Unexpected error during BMC verification. This may resolve after services stabilize.",
err=True,
)

# TODO: Phase 8 (Issue #10): Enroll node in Ironic
# TODO: Phase 9 (Issue #11): Configure Tempest

click.echo("\n✅ Initialization complete!")
click.echo(" Ironic API: http://localhost:6385")
click.echo(f" Virtual node: {node_name}")
click.echo(" BMC endpoint: http://localhost:8000/redfish/v1/")

def rebuild(self, service: str, no_cache: bool) -> None:
"""Rebuild a service after code changes."""
Expand Down
177 changes: 177 additions & 0 deletions stackbox/core/bmc.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
"""BMC (Baseboard Management Controller) utilities for Redfish."""

import time
from typing import Any

import click
import requests


class RedfishBMC:
"""Interact with Redfish BMC (sushy-tools)."""

def __init__(self, base_url: str = "http://localhost:8000"):
"""Initialize Redfish client.

Args:
base_url: Base URL of Redfish service
"""
self.base_url = base_url.rstrip("/")
self.session = requests.Session()
self.session.headers.update({"Content-Type": "application/json"})

def get_systems(self) -> list[str]:
"""Get list of systems.

Returns:
List of system IDs (UUIDs)

Raises:
requests.HTTPError: If API request fails
"""
resp = self.session.get(f"{self.base_url}/redfish/v1/Systems", timeout=10)
resp.raise_for_status()

data = resp.json()
members = data.get("Members", [])

return [m["@odata.id"].split("/")[-1] for m in members]

def get_system(self, system_id: str) -> dict[str, Any]:
"""Get system information.

Args:
system_id: System ID (usually VM UUID)

Returns:
System info dict

Raises:
requests.HTTPError: If API request fails
"""
resp = self.session.get(f"{self.base_url}/redfish/v1/Systems/{system_id}", timeout=10)
resp.raise_for_status()

return resp.json() # type: ignore[no-any-return]

def power_on(self, system_id: str) -> None:
"""Power on a system.

Args:
system_id: System ID

Raises:
requests.HTTPError: If API request fails
"""
self._reset_action(system_id, "On")
click.echo(f"✅ Powered on system {system_id}")

def power_off(self, system_id: str, force: bool = False) -> None:
"""Power off a system.

Args:
system_id: System ID
force: Force power off

Raises:
requests.HTTPError: If API request fails
"""
action = "ForceOff" if force else "GracefulShutdown"
self._reset_action(system_id, action)
click.echo(f"✅ Powered off system {system_id}")

def _reset_action(self, system_id: str, reset_type: str) -> None:
"""Send reset action to system.

Args:
system_id: System ID
reset_type: Reset type (On, ForceOff, GracefulShutdown, etc.)

Raises:
requests.HTTPError: If API request fails
"""
resp = self.session.post(
f"{self.base_url}/redfish/v1/Systems/{system_id}/Actions/ComputerSystem.Reset",
json={"ResetType": reset_type},
timeout=10,
)
resp.raise_for_status()

def set_boot_device(self, system_id: str, device: str, persistent: bool = False) -> None:
"""Set boot device.

Args:
system_id: System ID
device: Boot device (Pxe, Hdd, Cd, etc.)
persistent: Make setting persistent

Raises:
requests.HTTPError: If API request fails
"""
enabled = "Continuous" if persistent else "Once"

resp = self.session.patch(
f"{self.base_url}/redfish/v1/Systems/{system_id}",
json={
"Boot": {
"BootSourceOverrideTarget": device,
"BootSourceOverrideEnabled": enabled,
}
},
timeout=10,
)
resp.raise_for_status()

click.echo(f"✅ Set boot device to {device} ({'persistent' if persistent else 'once'})")

def get_power_state(self, system_id: str) -> str:
"""Get power state of system.

Args:
system_id: System ID

Returns:
Power state (On, Off, etc.)

Raises:
requests.HTTPError: If API request fails
"""
system = self.get_system(system_id)
return system.get("PowerState", "Unknown") # type: ignore[no-any-return]

def wait_for_power_state(self, system_id: str, state: str, timeout: int = 30) -> bool:
"""Wait for system to reach power state.

Args:
system_id: System ID
state: Desired power state
timeout: Max time to wait in seconds

Returns:
True if state reached, False if timeout

Raises:
requests.HTTPError: If API request fails
"""
start = time.time()

while time.time() - start < timeout:
current_state = self.get_power_state(system_id)
if current_state == state:
return True
time.sleep(2)

return False


def get_bmc_address_for_node(node_info: dict[str, Any]) -> str:
"""Get Redfish BMC address for a node.

Args:
node_info: Node info dict from VM creation

Returns:
Redfish system URL (container hostname for Ironic)
"""
uuid = node_info["uuid"]
return f"http://sushy-tools:8000/redfish/v1/Systems/{uuid}"
20 changes: 20 additions & 0 deletions stackbox/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,3 +51,23 @@ def generate_ironic_conf(

output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_text(config_content)

def generate_sushy_conf(
self,
output_path: Path,
debug: bool = True,
) -> None:
"""Generate sushy-emulator.conf from template.

Args:
output_path: Where to write sushy-emulator.conf
debug: Enable debug logging
"""
template = self.env.get_template("sushy/sushy-emulator.conf.j2")

config_content = template.render(
debug=debug,
)

output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_text(config_content)
3 changes: 3 additions & 0 deletions stackbox/templates/docker-compose.yml.j2
Original file line number Diff line number Diff line change
Expand Up @@ -66,10 +66,13 @@ services:
sushy-tools:
image: quay.io/metal3-io/sushy-tools:latest
container_name: stackbox-sushy-tools
command: sushy-emulator --config /etc/sushy/sushy-emulator.conf
networks:
- stackbox
ports:
- "8000:8000"
volumes:
- ./config/sushy:/etc/sushy:ro
environment:
- SUSHY_EMULATOR_LIBVIRT_URI=qemu+tcp://libvirt:16509/system
- SUSHY_EMULATOR_LISTEN_IP=0.0.0.0
Expand Down
32 changes: 32 additions & 0 deletions stackbox/templates/sushy/sushy-emulator.conf.j2
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# sushy-tools emulator configuration for containerized libvirt

[DEFAULT]
# Use libvirt backend
SUSHY_EMULATOR_BACKEND = libvirt

# Libvirt URI (TCP connection to containerized libvirt)
# Note: Unix socket not available - libvirt runs in separate container
SUSHY_EMULATOR_LIBVIRT_URI = qemu+tcp://libvirt:16509/system

# Listen on all interfaces inside container
SUSHY_EMULATOR_LISTEN_IP = 0.0.0.0
SUSHY_EMULATOR_LISTEN_PORT = 8000

# SSL (disabled for local dev)
SUSHY_EMULATOR_SSL_CERT = None
SUSHY_EMULATOR_SSL_KEY = None

# Authentication (disabled for local dev - noauth in Ironic)
SUSHY_EMULATOR_AUTH_FILE = None

# Boot device persistence
SUSHY_EMULATOR_BOOT_LOADER_MAP = {}

# Ignore boot device errors
SUSHY_EMULATOR_IGNORE_BOOT_DEVICE = False

# Virtual media
SUSHY_EMULATOR_VMEDIA_VERIFY_SSL = False

# Logging
SUSHY_EMULATOR_DEBUG = {{ debug }}
Loading
Loading