diff --git a/stackbox/cli/__main__.py b/stackbox/cli/__main__.py index d72befb..5a74fb7 100644 --- a/stackbox/cli/__main__.py +++ b/stackbox/cli/__main__.py @@ -2,6 +2,7 @@ from pathlib import Path import sys +import time import click @@ -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" @@ -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.""" diff --git a/stackbox/core/bmc.py b/stackbox/core/bmc.py new file mode 100644 index 0000000..fabbe16 --- /dev/null +++ b/stackbox/core/bmc.py @@ -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}" diff --git a/stackbox/core/config.py b/stackbox/core/config.py index bf9d47d..286655a 100644 --- a/stackbox/core/config.py +++ b/stackbox/core/config.py @@ -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) diff --git a/stackbox/templates/docker-compose.yml.j2 b/stackbox/templates/docker-compose.yml.j2 index 264e24e..dfb8282 100644 --- a/stackbox/templates/docker-compose.yml.j2 +++ b/stackbox/templates/docker-compose.yml.j2 @@ -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 diff --git a/stackbox/templates/sushy/sushy-emulator.conf.j2 b/stackbox/templates/sushy/sushy-emulator.conf.j2 new file mode 100644 index 0000000..5c647c8 --- /dev/null +++ b/stackbox/templates/sushy/sushy-emulator.conf.j2 @@ -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 }} diff --git a/tests/unit/core/test_bmc.py b/tests/unit/core/test_bmc.py new file mode 100644 index 0000000..ba4eb94 --- /dev/null +++ b/tests/unit/core/test_bmc.py @@ -0,0 +1,370 @@ +"""Tests for stackbox.core.bmc module.""" + +from unittest.mock import MagicMock, patch + +import pytest +import requests + +from stackbox.core.bmc import RedfishBMC, get_bmc_address_for_node + + +class TestRedfishBMCInit: + """Tests for RedfishBMC initialization.""" + + def test_init_with_default_url(self) -> None: + """Test initialization with default base URL.""" + bmc = RedfishBMC() + + assert bmc.base_url == "http://localhost:8000" + assert bmc.session is not None + assert bmc.session.headers["Content-Type"] == "application/json" + + def test_init_with_custom_url(self) -> None: + """Test initialization with custom base URL.""" + bmc = RedfishBMC("http://custom:9000") + + assert bmc.base_url == "http://custom:9000" + + def test_init_strips_trailing_slash(self) -> None: + """Test that trailing slash is removed from base URL.""" + bmc = RedfishBMC("http://localhost:8000/") + + assert bmc.base_url == "http://localhost:8000" + + +class TestGetSystems: + """Tests for get_systems method.""" + + @patch("requests.Session.get") + def test_get_systems_returns_list_of_uuids(self, mock_get: MagicMock) -> None: + """Test that get_systems returns list of system UUIDs.""" + mock_response = MagicMock() + mock_response.json.return_value = { + "Members": [ + {"@odata.id": "/redfish/v1/Systems/uuid-1"}, + {"@odata.id": "/redfish/v1/Systems/uuid-2"}, + ] + } + mock_get.return_value = mock_response + + bmc = RedfishBMC() + systems = bmc.get_systems() + + assert systems == ["uuid-1", "uuid-2"] + mock_get.assert_called_once_with("http://localhost:8000/redfish/v1/Systems", timeout=10) + mock_response.raise_for_status.assert_called_once() + + @patch("requests.Session.get") + def test_get_systems_returns_empty_list_when_no_members(self, mock_get: MagicMock) -> None: + """Test that get_systems returns empty list when no systems.""" + mock_response = MagicMock() + mock_response.json.return_value = {"Members": []} + mock_get.return_value = mock_response + + bmc = RedfishBMC() + systems = bmc.get_systems() + + assert systems == [] + + @patch("requests.Session.get") + def test_get_systems_raises_on_http_error(self, mock_get: MagicMock) -> None: + """Test that get_systems raises HTTPError on failure.""" + mock_response = MagicMock() + mock_response.raise_for_status.side_effect = requests.HTTPError("500 Error") + mock_get.return_value = mock_response + + bmc = RedfishBMC() + + with pytest.raises(requests.HTTPError): + bmc.get_systems() + + +class TestGetSystem: + """Tests for get_system method.""" + + @patch("requests.Session.get") + def test_get_system_returns_system_info(self, mock_get: MagicMock) -> None: + """Test that get_system returns system information.""" + mock_response = MagicMock() + mock_response.json.return_value = { + "Id": "test-uuid", + "PowerState": "On", + "Boot": {"BootSourceOverrideTarget": "Pxe"}, + } + mock_get.return_value = mock_response + + bmc = RedfishBMC() + system = bmc.get_system("test-uuid") + + assert system["Id"] == "test-uuid" + assert system["PowerState"] == "On" + mock_get.assert_called_once_with( + "http://localhost:8000/redfish/v1/Systems/test-uuid", timeout=10 + ) + mock_response.raise_for_status.assert_called_once() + + @patch("requests.Session.get") + def test_get_system_raises_on_http_error(self, mock_get: MagicMock) -> None: + """Test that get_system raises HTTPError on failure.""" + mock_response = MagicMock() + mock_response.raise_for_status.side_effect = requests.HTTPError("404 Not Found") + mock_get.return_value = mock_response + + bmc = RedfishBMC() + + with pytest.raises(requests.HTTPError): + bmc.get_system("nonexistent-uuid") + + +class TestPowerOn: + """Tests for power_on method.""" + + @patch("click.echo") + @patch("requests.Session.post") + def test_power_on_sends_correct_reset_action( + self, mock_post: MagicMock, mock_echo: MagicMock + ) -> None: + """Test that power_on sends On reset action.""" + mock_response = MagicMock() + mock_post.return_value = mock_response + + bmc = RedfishBMC() + bmc.power_on("test-uuid") + + mock_post.assert_called_once_with( + "http://localhost:8000/redfish/v1/Systems/test-uuid/Actions/ComputerSystem.Reset", + json={"ResetType": "On"}, + timeout=10, + ) + mock_response.raise_for_status.assert_called_once() + mock_echo.assert_called_once() + + @patch("requests.Session.post") + def test_power_on_raises_on_http_error(self, mock_post: MagicMock) -> None: + """Test that power_on raises HTTPError on failure.""" + mock_response = MagicMock() + mock_response.raise_for_status.side_effect = requests.HTTPError("500 Error") + mock_post.return_value = mock_response + + bmc = RedfishBMC() + + with pytest.raises(requests.HTTPError): + bmc.power_on("test-uuid") + + +class TestPowerOff: + """Tests for power_off method.""" + + @patch("click.echo") + @patch("requests.Session.post") + def test_power_off_graceful_shutdown(self, mock_post: MagicMock, mock_echo: MagicMock) -> None: + """Test that power_off sends GracefulShutdown by default.""" + mock_response = MagicMock() + mock_post.return_value = mock_response + + bmc = RedfishBMC() + bmc.power_off("test-uuid", force=False) + + mock_post.assert_called_once_with( + "http://localhost:8000/redfish/v1/Systems/test-uuid/Actions/ComputerSystem.Reset", + json={"ResetType": "GracefulShutdown"}, + timeout=10, + ) + mock_response.raise_for_status.assert_called_once() + + @patch("click.echo") + @patch("requests.Session.post") + def test_power_off_force_shutdown(self, mock_post: MagicMock, mock_echo: MagicMock) -> None: + """Test that power_off sends ForceOff when force=True.""" + mock_response = MagicMock() + mock_post.return_value = mock_response + + bmc = RedfishBMC() + bmc.power_off("test-uuid", force=True) + + mock_post.assert_called_once_with( + "http://localhost:8000/redfish/v1/Systems/test-uuid/Actions/ComputerSystem.Reset", + json={"ResetType": "ForceOff"}, + timeout=10, + ) + + @patch("requests.Session.post") + def test_power_off_raises_on_http_error(self, mock_post: MagicMock) -> None: + """Test that power_off raises HTTPError on failure.""" + mock_response = MagicMock() + mock_response.raise_for_status.side_effect = requests.HTTPError("500 Error") + mock_post.return_value = mock_response + + bmc = RedfishBMC() + + with pytest.raises(requests.HTTPError): + bmc.power_off("test-uuid") + + +class TestSetBootDevice: + """Tests for set_boot_device method.""" + + @patch("click.echo") + @patch("requests.Session.patch") + def test_set_boot_device_once(self, mock_patch: MagicMock, mock_echo: MagicMock) -> None: + """Test that set_boot_device sends Once mode by default.""" + mock_response = MagicMock() + mock_patch.return_value = mock_response + + bmc = RedfishBMC() + bmc.set_boot_device("test-uuid", "Pxe", persistent=False) + + mock_patch.assert_called_once_with( + "http://localhost:8000/redfish/v1/Systems/test-uuid", + json={ + "Boot": { + "BootSourceOverrideTarget": "Pxe", + "BootSourceOverrideEnabled": "Once", + } + }, + timeout=10, + ) + mock_response.raise_for_status.assert_called_once() + + @patch("click.echo") + @patch("requests.Session.patch") + def test_set_boot_device_persistent(self, mock_patch: MagicMock, mock_echo: MagicMock) -> None: + """Test that set_boot_device sends Continuous when persistent=True.""" + mock_response = MagicMock() + mock_patch.return_value = mock_response + + bmc = RedfishBMC() + bmc.set_boot_device("test-uuid", "Hdd", persistent=True) + + mock_patch.assert_called_once_with( + "http://localhost:8000/redfish/v1/Systems/test-uuid", + json={ + "Boot": { + "BootSourceOverrideTarget": "Hdd", + "BootSourceOverrideEnabled": "Continuous", + } + }, + timeout=10, + ) + + @patch("requests.Session.patch") + def test_set_boot_device_raises_on_http_error(self, mock_patch: MagicMock) -> None: + """Test that set_boot_device raises HTTPError on failure.""" + mock_response = MagicMock() + mock_response.raise_for_status.side_effect = requests.HTTPError("400 Error") + mock_patch.return_value = mock_response + + bmc = RedfishBMC() + + with pytest.raises(requests.HTTPError): + bmc.set_boot_device("test-uuid", "Pxe") + + +class TestGetPowerState: + """Tests for get_power_state method.""" + + @patch("stackbox.core.bmc.RedfishBMC.get_system") + def test_get_power_state_returns_state(self, mock_get_system: MagicMock) -> None: + """Test that get_power_state extracts PowerState from system info.""" + mock_get_system.return_value = {"PowerState": "On"} + + bmc = RedfishBMC() + state = bmc.get_power_state("test-uuid") + + assert state == "On" + mock_get_system.assert_called_once_with("test-uuid") + + @patch("stackbox.core.bmc.RedfishBMC.get_system") + def test_get_power_state_returns_unknown_when_missing(self, mock_get_system: MagicMock) -> None: + """Test that get_power_state returns Unknown when PowerState missing.""" + mock_get_system.return_value = {} + + bmc = RedfishBMC() + state = bmc.get_power_state("test-uuid") + + assert state == "Unknown" + + +class TestWaitForPowerState: + """Tests for wait_for_power_state method.""" + + @patch("time.sleep") + @patch("stackbox.core.bmc.RedfishBMC.get_power_state") + def test_wait_for_power_state_returns_true_when_state_reached( + self, mock_get_state: MagicMock, mock_sleep: MagicMock + ) -> None: + """Test that wait_for_power_state returns True when state is reached.""" + mock_get_state.return_value = "On" + + bmc = RedfishBMC() + result = bmc.wait_for_power_state("test-uuid", "On", timeout=30) + + assert result is True + mock_get_state.assert_called_once_with("test-uuid") + mock_sleep.assert_not_called() + + @patch("time.time") + @patch("time.sleep") + @patch("stackbox.core.bmc.RedfishBMC.get_power_state") + def test_wait_for_power_state_returns_false_on_timeout( + self, + mock_get_state: MagicMock, + mock_sleep: MagicMock, + mock_time: MagicMock, + ) -> None: + """Test that wait_for_power_state returns False on timeout.""" + mock_get_state.return_value = "Off" + # Simulate timeout after first check + mock_time.side_effect = [0, 35] + + bmc = RedfishBMC() + result = bmc.wait_for_power_state("test-uuid", "On", timeout=30) + + assert result is False + + @patch("time.time") + @patch("time.sleep") + @patch("stackbox.core.bmc.RedfishBMC.get_power_state") + def test_wait_for_power_state_polls_until_state_reached( + self, + mock_get_state: MagicMock, + mock_sleep: MagicMock, + mock_time: MagicMock, + ) -> None: + """Test that wait_for_power_state polls multiple times.""" + # State changes after 2 polls + mock_get_state.side_effect = ["Off", "Off", "On"] + mock_time.side_effect = [0, 2, 4, 6] + + bmc = RedfishBMC() + result = bmc.wait_for_power_state("test-uuid", "On", timeout=30) + + assert result is True + assert mock_get_state.call_count == 3 + assert mock_sleep.call_count == 2 + + +class TestGetBMCAddress: + """Tests for get_bmc_address_for_node helper function.""" + + def test_get_bmc_address_constructs_correct_url(self) -> None: + """Test that get_bmc_address_for_node constructs Redfish URL.""" + node_info = { + "uuid": "abc-123-def-456", + "name": "stackbox-node-0", + "mac": "52:54:00:12:34:56", + } + + url = get_bmc_address_for_node(node_info) + + assert url == "http://sushy-tools:8000/redfish/v1/Systems/abc-123-def-456" + + def test_get_bmc_address_uses_container_hostname(self) -> None: + """Test that BMC address uses container hostname (not localhost).""" + node_info = {"uuid": "test-uuid"} + + url = get_bmc_address_for_node(node_info) + + # Should use sushy-tools hostname for container-to-container communication + assert "sushy-tools:8000" in url + assert "localhost" not in url diff --git a/tests/unit/core/test_config.py b/tests/unit/core/test_config.py index 705f586..d50a782 100644 --- a/tests/unit/core/test_config.py +++ b/tests/unit/core/test_config.py @@ -73,3 +73,59 @@ def test_generate_creates_parent_directories(self, tmp_path: Path) -> None: # Now they should exist assert output_file.parent.exists() assert output_file.exists() + + def test_generate_sushy_conf_with_debug_true(self, tmp_path: Path) -> None: + """Test generating sushy-emulator.conf with debug=True.""" + gen = ConfigGenerator() + output_file = tmp_path / "sushy-emulator.conf" + + gen.generate_sushy_conf(output_path=output_file, debug=True) + + # File should exist + assert output_file.exists() + + # Should contain TCP libvirt URI + content = output_file.read_text() + assert "qemu+tcp://libvirt:16509/system" in content + assert "SUSHY_EMULATOR_BACKEND = libvirt" in content + assert "SUSHY_EMULATOR_LISTEN_IP = 0.0.0.0" in content + assert "SUSHY_EMULATOR_LISTEN_PORT = 8000" in content + assert "SUSHY_EMULATOR_DEBUG = True" in content + + def test_generate_sushy_conf_with_debug_false(self, tmp_path: Path) -> None: + """Test generating sushy-emulator.conf with debug=False.""" + gen = ConfigGenerator() + output_file = tmp_path / "sushy-emulator.conf" + + gen.generate_sushy_conf(output_path=output_file, debug=False) + + content = output_file.read_text() + assert "SUSHY_EMULATOR_DEBUG = False" in content + + def test_generate_sushy_conf_creates_parent_directories(self, tmp_path: Path) -> None: + """Test that sushy config generates parent directories.""" + gen = ConfigGenerator() + output_file = tmp_path / "config" / "sushy" / "sushy-emulator.conf" + + # Parent directories don't exist yet + assert not output_file.parent.exists() + + gen.generate_sushy_conf(output_path=output_file, debug=True) + + # Now they should exist + assert output_file.parent.exists() + assert output_file.exists() + + def test_generate_sushy_conf_uses_tcp_not_socket(self, tmp_path: Path) -> None: + """Test that sushy config uses TCP connection (not Unix socket).""" + gen = ConfigGenerator() + output_file = tmp_path / "sushy-emulator.conf" + + gen.generate_sushy_conf(output_path=output_file) + + content = output_file.read_text() + # Should use TCP + assert "qemu+tcp://libvirt:16509/system" in content + # Should NOT use Unix socket + assert "qemu+unix" not in content + assert "libvirt-sock" not in content