From aa6263ff346f6ba704635ed9df1aa46d6c31e30f Mon Sep 17 00:00:00 2001 From: Abhishek Bongale Date: Fri, 22 May 2026 10:41:58 +0100 Subject: [PATCH] Enroll Node in Ironic - Created node in Ironic via API - Configured Redfish driver with BMC address - Created port for PXE boot - Setted node properties (CPU, memory, disk) - Verified node is manageable by Ironic - Documented enrollment process Closes: #10 Signed-off-by: Abhishek Bongale --- stackbox/cli/__main__.py | 52 +++- stackbox/core/enrollment.py | 305 +++++++++++++++++++ tests/unit/core/test_enrollment.py | 467 +++++++++++++++++++++++++++++ 3 files changed, 818 insertions(+), 6 deletions(-) create mode 100644 stackbox/core/enrollment.py create mode 100644 tests/unit/core/test_enrollment.py diff --git a/stackbox/cli/__main__.py b/stackbox/cli/__main__.py index 5a74fb7..6c32f7f 100644 --- a/stackbox/cli/__main__.py +++ b/stackbox/cli/__main__.py @@ -5,6 +5,9 @@ import time import click +from requests import ConnectionError as RequestsConnectionError +from requests import HTTPError as RequestsHTTPError +from requests import Timeout as RequestsTimeout from stackbox import __version__ from stackbox.core import builder @@ -368,8 +371,6 @@ def init( # 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 @@ -396,7 +397,7 @@ def init( click.echo(f" āš ļø {node_name} not found in BMC", err=True) click.echo(" This may resolve after services stabilize") - except requests.ConnectionError as e: + except RequestsConnectionError 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" @@ -405,7 +406,7 @@ def init( err=True, ) - except requests.Timeout as e: + except RequestsTimeout as e: # Scenario 2: sushy-tools not responding click.echo( f"\nāš ļø BMC verification timeout: {e}\n" @@ -414,7 +415,7 @@ def init( err=True, ) - except requests.HTTPError as e: + except RequestsHTTPError as e: # Scenario 3: HTTP error from sushy-tools (500, 404, etc.) status_code = e.response.status_code if e.response else "unknown" @@ -440,13 +441,52 @@ def init( err=True, ) - # TODO: Phase 8 (Issue #10): Enroll node in Ironic + # Phase 8: Enroll nodes in Ironic + click.echo("\nšŸ“ Enrolling node in Ironic...") + try: + from stackbox.core import enrollment + + ironic_client = enrollment.IronicClient("http://localhost:6385") + + # BMC address for container-to-container communication + bmc_address = "http://sushy-tools:8000" + + # Enroll node + enrolled_node = enrollment.enroll_node( + ironic_client, + node_info, + bmc_address, + ) + + click.echo(f"āœ… Node enrolled: {enrolled_node['uuid']}") + + # Verify power control + click.echo("šŸ”Œ Verifying power control...") + if enrollment.verify_node_power_control(ironic_client, enrolled_node["uuid"]): + click.echo("āœ… Power control verified") + else: + click.echo("āš ļø Power control verification failed", err=True) + click.echo(" Node is enrolled but power control may not work") + + except RequestsConnectionError as e: + click.echo( + f"\nāŒ Cannot connect to Ironic API at http://localhost:6385: {e}\n" + "Ironic API service is not running or unreachable.", + err=True, + ) + sys.exit(1) + + except RuntimeError as e: + click.echo(f"\nāŒ Failed to enroll node: {e}", err=True) + sys.exit(1) + # 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/") + click.echo(f" Enrolled in Ironic: {enrolled_node['uuid']}") def rebuild(self, service: str, no_cache: bool) -> None: """Rebuild a service after code changes.""" diff --git a/stackbox/core/enrollment.py b/stackbox/core/enrollment.py new file mode 100644 index 0000000..9af0300 --- /dev/null +++ b/stackbox/core/enrollment.py @@ -0,0 +1,305 @@ +"""Ironic node enrollment and management.""" + +import time +from typing import Any + +import click +import requests + + +class IronicClient: + """Interact with Ironic API for node management.""" + + def __init__(self, api_url: str = "http://localhost:6385"): + """Initialize Ironic API client. + + Args: + api_url: Base URL of Ironic API + """ + self.api_url = api_url.rstrip("/") + self.session = requests.Session() + self.session.headers.update( + { + "Content-Type": "application/json", + "X-OpenStack-Ironic-API-Version": "1.80", + } + ) + + def create_node( + self, + name: str, + driver: str, + driver_info: dict[str, Any], + properties: dict[str, Any], + resource_class: str = "baremetal", + ) -> dict[str, Any]: + """Create a node in Ironic. + + Args: + name: Node name + driver: Hardware driver (e.g., 'redfish') + driver_info: Driver-specific configuration + properties: Node properties (CPU, memory, disk, etc.) + resource_class: Resource class for scheduling + + Returns: + Created node data + + Raises: + RuntimeError: If node creation fails + """ + node_data = { + "name": name, + "driver": driver, + "driver_info": driver_info, + "properties": properties, + "resource_class": resource_class, + } + + resp = self.session.post( + f"{self.api_url}/v1/nodes", + json=node_data, + timeout=30, + ) + + if resp.status_code == 201: + click.echo(f"āœ… Created node '{name}'") + return resp.json() # type: ignore[no-any-return] + + elif resp.status_code == 409: + raise RuntimeError( + f"Node '{name}' already exists in Ironic. " + f"Either delete it first or use a different name." + ) + + elif resp.status_code == 400: + try: + error_detail = resp.json().get("error_message", resp.text) + except Exception: + error_detail = resp.text + + raise RuntimeError( + f"Invalid node configuration: {error_detail}\n" + "Common issues:\n" + " - Invalid Redfish driver_info (check BMC address and credentials)\n" + " - Invalid properties (check CPU, memory, disk values)\n" + " - Missing required fields" + ) + + elif resp.status_code == 500: + raise RuntimeError( + f"Ironic internal server error: {resp.text}\n" + "This usually indicates conductor issues." + ) + + else: + raise RuntimeError(f"Failed to create node (HTTP {resp.status_code}): {resp.text}") + + def create_port( + self, + node_uuid: str, + mac_address: str, + pxe_enabled: bool = True, + ) -> dict[str, Any]: + """Create a port for a node. + + Args: + node_uuid: UUID of the node + mac_address: MAC address for the port + pxe_enabled: Enable PXE boot on this port + + Returns: + Created port data + + Raises: + RuntimeError: If port creation fails + """ + port_data = { + "node_uuid": node_uuid, + "address": mac_address, + "pxe_enabled": pxe_enabled, + } + + resp = self.session.post( + f"{self.api_url}/v1/ports", + json=port_data, + timeout=30, + ) + + if resp.status_code == 201: + click.echo(f"āœ… Created port for MAC {mac_address}") + return resp.json() # type: ignore[no-any-return] + + elif resp.status_code == 409: + raise RuntimeError( + f"Port with MAC {mac_address} already exists. " + f"Delete it first or use a different MAC address." + ) + + elif resp.status_code == 400: + try: + error_detail = resp.json().get("error_message", resp.text) + except Exception: + error_detail = resp.text + + raise RuntimeError( + f"Invalid port configuration: {error_detail}\n" + "Check that the node UUID is valid and MAC address is properly formatted." + ) + + else: + raise RuntimeError(f"Failed to create port (HTTP {resp.status_code}): {resp.text}") + + def list_nodes(self) -> list[dict[str, Any]]: + """List all nodes. + + Returns: + List of node data + + Raises: + requests.HTTPError: If API request fails + """ + resp = self.session.get(f"{self.api_url}/v1/nodes", timeout=10) + resp.raise_for_status() + + data = resp.json() + return data.get("nodes", []) # type: ignore[no-any-return] + + def get_node(self, node_id: str) -> dict[str, Any]: + """Get node information. + + Args: + node_id: Node UUID or name + + Returns: + Node data + + Raises: + requests.HTTPError: If API request fails + """ + resp = self.session.get(f"{self.api_url}/v1/nodes/{node_id}", timeout=10) + resp.raise_for_status() + + return resp.json() # type: ignore[no-any-return] + + def set_node_power_state(self, node_id: str, target: str) -> None: + """Set node power state. + + Args: + node_id: Node UUID or name + target: Target power state ('on' or 'off') + + Raises: + requests.HTTPError: If API request fails + """ + resp = self.session.put( + f"{self.api_url}/v1/nodes/{node_id}/states/power", + json={"target": target}, + timeout=30, + ) + resp.raise_for_status() + + def get_node_power_state(self, node_id: str) -> str | None: + """Get node power state. + + Args: + node_id: Node UUID or name + + Returns: + Power state string or None if not available + + Raises: + requests.HTTPError: If API request fails + """ + node = self.get_node(node_id) + return node.get("power_state") + + +def enroll_node( + ironic_client: IronicClient, + node_info: dict[str, Any], + bmc_address: str, +) -> dict[str, Any]: + """Enroll a node in Ironic. + + Args: + ironic_client: Ironic API client + node_info: Node information from VM creation + bmc_address: BMC base address (e.g., http://sushy-tools:8000) + + Returns: + Enrolled node data + + Raises: + RuntimeError: If enrollment fails + """ + # Prepare node data + driver_info = { + "redfish_address": bmc_address, + "redfish_system_id": f"/redfish/v1/Systems/{node_info['uuid']}", + "redfish_username": "admin", + "redfish_password": "password", + "redfish_verify_ca": False, + } + + properties = { + "cpus": node_info["vcpus"], + "memory_mb": node_info["memory_mb"], + "local_gb": node_info["disk_gb"], + "cpu_arch": "x86_64", + "capabilities": "boot_mode:bios,boot_option:local", + } + + # Create node + node = ironic_client.create_node( + name=node_info["name"], + driver="redfish", + driver_info=driver_info, + properties=properties, + resource_class="baremetal", + ) + + # Create port for PXE boot + ironic_client.create_port( + node_uuid=node["uuid"], + mac_address=node_info["mac"], + pxe_enabled=True, + ) + + return node + + +def verify_node_power_control(ironic_client: IronicClient, node_id: str) -> bool: + """Verify that Ironic can control node power. + + Args: + ironic_client: Ironic API client + node_id: Node UUID or name + + Returns: + True if power control works, False otherwise + """ + try: + # Power on + ironic_client.set_node_power_state(node_id, "on") + time.sleep(5) + + state = ironic_client.get_node_power_state(node_id) + if state != "power on": + click.echo(f"āš ļø Expected 'power on', got '{state}'", err=True) + return False + + # Power off + ironic_client.set_node_power_state(node_id, "off") + time.sleep(5) + + state = ironic_client.get_node_power_state(node_id) + if state != "power off": + click.echo(f"āš ļø Expected 'power off', got '{state}'", err=True) + return False + + return True + + except Exception as e: + click.echo(f"āš ļø Power control verification error: {e}", err=True) + return False diff --git a/tests/unit/core/test_enrollment.py b/tests/unit/core/test_enrollment.py new file mode 100644 index 0000000..0ecf63d --- /dev/null +++ b/tests/unit/core/test_enrollment.py @@ -0,0 +1,467 @@ +"""Tests for stackbox.core.enrollment module.""" + +from unittest.mock import MagicMock, patch + +import pytest +import requests + +from stackbox.core.enrollment import ( + IronicClient, + enroll_node, + verify_node_power_control, +) + + +class TestIronicClientInit: + """Tests for IronicClient initialization.""" + + def test_init_with_default_url(self) -> None: + """Test initialization with default API URL.""" + client = IronicClient() + + assert client.api_url == "http://localhost:6385" + assert client.session is not None + assert client.session.headers["Content-Type"] == "application/json" + assert client.session.headers["X-OpenStack-Ironic-API-Version"] == "1.80" + + def test_init_with_custom_url(self) -> None: + """Test initialization with custom API URL.""" + client = IronicClient("http://custom:9999") + + assert client.api_url == "http://custom:9999" + + def test_init_strips_trailing_slash(self) -> None: + """Test that trailing slash is removed from API URL.""" + client = IronicClient("http://localhost:6385/") + + assert client.api_url == "http://localhost:6385" + + +class TestCreateNode: + """Tests for create_node method.""" + + @patch("click.echo") + @patch("requests.Session.post") + def test_create_node_success(self, mock_post: MagicMock, mock_echo: MagicMock) -> None: + """Test successful node creation.""" + mock_response = MagicMock() + mock_response.status_code = 201 + mock_response.json.return_value = { + "uuid": "test-uuid", + "name": "test-node", + "driver": "redfish", + "provision_state": "enroll", + } + mock_post.return_value = mock_response + + client = IronicClient() + node = client.create_node( + name="test-node", + driver="redfish", + driver_info={"redfish_address": "http://bmc:8000"}, + properties={"cpus": 2, "memory_mb": 4096}, + resource_class="baremetal", + ) + + assert node["uuid"] == "test-uuid" + assert node["name"] == "test-node" + mock_post.assert_called_once() + mock_echo.assert_called_once() + + @patch("requests.Session.post") + def test_create_node_already_exists(self, mock_post: MagicMock) -> None: + """Test node creation fails when node already exists (409).""" + mock_response = MagicMock() + mock_response.status_code = 409 + mock_post.return_value = mock_response + + client = IronicClient() + + with pytest.raises(RuntimeError, match="already exists"): + client.create_node( + name="test-node", + driver="redfish", + driver_info={}, + properties={}, + ) + + @patch("requests.Session.post") + def test_create_node_invalid_configuration(self, mock_post: MagicMock) -> None: + """Test node creation fails with invalid configuration (400).""" + mock_response = MagicMock() + mock_response.status_code = 400 + mock_response.json.return_value = {"error_message": "Invalid driver_info"} + mock_response.text = "Bad Request" + mock_post.return_value = mock_response + + client = IronicClient() + + with pytest.raises(RuntimeError, match="Invalid node configuration"): + client.create_node( + name="test-node", + driver="redfish", + driver_info={}, + properties={}, + ) + + @patch("requests.Session.post") + def test_create_node_server_error(self, mock_post: MagicMock) -> None: + """Test node creation fails with server error (500).""" + mock_response = MagicMock() + mock_response.status_code = 500 + mock_response.text = "Internal Server Error" + mock_post.return_value = mock_response + + client = IronicClient() + + with pytest.raises(RuntimeError, match="Ironic internal server error"): + client.create_node( + name="test-node", + driver="redfish", + driver_info={}, + properties={}, + ) + + +class TestCreatePort: + """Tests for create_port method.""" + + @patch("click.echo") + @patch("requests.Session.post") + def test_create_port_success(self, mock_post: MagicMock, mock_echo: MagicMock) -> None: + """Test successful port creation.""" + mock_response = MagicMock() + mock_response.status_code = 201 + mock_response.json.return_value = { + "uuid": "port-uuid", + "address": "52:54:00:12:34:56", + "node_uuid": "node-uuid", + } + mock_post.return_value = mock_response + + client = IronicClient() + port = client.create_port( + node_uuid="node-uuid", + mac_address="52:54:00:12:34:56", + pxe_enabled=True, + ) + + assert port["uuid"] == "port-uuid" + assert port["address"] == "52:54:00:12:34:56" + mock_post.assert_called_once() + mock_echo.assert_called_once() + + @patch("requests.Session.post") + def test_create_port_duplicate_mac(self, mock_post: MagicMock) -> None: + """Test port creation fails with duplicate MAC (409).""" + mock_response = MagicMock() + mock_response.status_code = 409 + mock_post.return_value = mock_response + + client = IronicClient() + + with pytest.raises(RuntimeError, match="already exists"): + client.create_port( + node_uuid="node-uuid", + mac_address="52:54:00:12:34:56", + ) + + @patch("requests.Session.post") + def test_create_port_invalid_node(self, mock_post: MagicMock) -> None: + """Test port creation fails with invalid node UUID (400).""" + mock_response = MagicMock() + mock_response.status_code = 400 + mock_response.json.return_value = {"error_message": "Invalid node_uuid"} + mock_response.text = "Bad Request" + mock_post.return_value = mock_response + + client = IronicClient() + + with pytest.raises(RuntimeError, match="Invalid port configuration"): + client.create_port( + node_uuid="invalid-uuid", + mac_address="52:54:00:12:34:56", + ) + + +class TestListNodes: + """Tests for list_nodes method.""" + + @patch("requests.Session.get") + def test_list_nodes_returns_nodes(self, mock_get: MagicMock) -> None: + """Test that list_nodes returns list of nodes.""" + mock_response = MagicMock() + mock_response.json.return_value = { + "nodes": [ + {"uuid": "node-1", "name": "node-1"}, + {"uuid": "node-2", "name": "node-2"}, + ] + } + mock_get.return_value = mock_response + + client = IronicClient() + nodes = client.list_nodes() + + assert len(nodes) == 2 + assert nodes[0]["uuid"] == "node-1" + mock_get.assert_called_once_with("http://localhost:6385/v1/nodes", timeout=10) + mock_response.raise_for_status.assert_called_once() + + @patch("requests.Session.get") + def test_list_nodes_returns_empty_list(self, mock_get: MagicMock) -> None: + """Test that list_nodes returns empty list when no nodes.""" + mock_response = MagicMock() + mock_response.json.return_value = {"nodes": []} + mock_get.return_value = mock_response + + client = IronicClient() + nodes = client.list_nodes() + + assert nodes == [] + + +class TestGetNode: + """Tests for get_node method.""" + + @patch("requests.Session.get") + def test_get_node_returns_node_info(self, mock_get: MagicMock) -> None: + """Test that get_node returns node information.""" + mock_response = MagicMock() + mock_response.json.return_value = { + "uuid": "test-uuid", + "name": "test-node", + "driver": "redfish", + "provision_state": "enroll", + "power_state": "power off", + } + mock_get.return_value = mock_response + + client = IronicClient() + node = client.get_node("test-uuid") + + assert node["uuid"] == "test-uuid" + assert node["name"] == "test-node" + mock_get.assert_called_once_with("http://localhost:6385/v1/nodes/test-uuid", timeout=10) + mock_response.raise_for_status.assert_called_once() + + @patch("requests.Session.get") + def test_get_node_raises_on_not_found(self, mock_get: MagicMock) -> None: + """Test that get_node raises HTTPError on 404.""" + mock_response = MagicMock() + mock_response.raise_for_status.side_effect = requests.HTTPError("404 Not Found") + mock_get.return_value = mock_response + + client = IronicClient() + + with pytest.raises(requests.HTTPError): + client.get_node("nonexistent-uuid") + + +class TestSetNodePowerState: + """Tests for set_node_power_state method.""" + + @patch("requests.Session.put") + def test_set_power_state_on(self, mock_put: MagicMock) -> None: + """Test setting node power state to on.""" + mock_response = MagicMock() + mock_put.return_value = mock_response + + client = IronicClient() + client.set_node_power_state("test-uuid", "on") + + mock_put.assert_called_once_with( + "http://localhost:6385/v1/nodes/test-uuid/states/power", + json={"target": "on"}, + timeout=30, + ) + mock_response.raise_for_status.assert_called_once() + + @patch("requests.Session.put") + def test_set_power_state_off(self, mock_put: MagicMock) -> None: + """Test setting node power state to off.""" + mock_response = MagicMock() + mock_put.return_value = mock_response + + client = IronicClient() + client.set_node_power_state("test-uuid", "off") + + mock_put.assert_called_once_with( + "http://localhost:6385/v1/nodes/test-uuid/states/power", + json={"target": "off"}, + timeout=30, + ) + + @patch("requests.Session.put") + def test_set_power_state_invalid_target(self, mock_put: MagicMock) -> None: + """Test setting invalid power state raises HTTPError.""" + mock_response = MagicMock() + mock_response.raise_for_status.side_effect = requests.HTTPError("400 Bad Request") + mock_put.return_value = mock_response + + client = IronicClient() + + with pytest.raises(requests.HTTPError): + client.set_node_power_state("test-uuid", "invalid") + + +class TestGetNodePowerState: + """Tests for get_node_power_state method.""" + + @patch("stackbox.core.enrollment.IronicClient.get_node") + def test_get_power_state_returns_state(self, mock_get_node: MagicMock) -> None: + """Test that get_node_power_state extracts power state.""" + mock_get_node.return_value = {"power_state": "power on"} + + client = IronicClient() + state = client.get_node_power_state("test-uuid") + + assert state == "power on" + mock_get_node.assert_called_once_with("test-uuid") + + @patch("stackbox.core.enrollment.IronicClient.get_node") + def test_get_power_state_returns_none_when_missing(self, mock_get_node: MagicMock) -> None: + """Test that get_node_power_state returns None when missing.""" + mock_get_node.return_value = {} + + client = IronicClient() + state = client.get_node_power_state("test-uuid") + + assert state is None + + +class TestEnrollNode: + """Tests for enroll_node helper function.""" + + @patch("click.echo") + @patch("stackbox.core.enrollment.IronicClient.create_port") + @patch("stackbox.core.enrollment.IronicClient.create_node") + def test_enroll_node_full_flow( + self, + mock_create_node: MagicMock, + mock_create_port: MagicMock, + mock_echo: MagicMock, + ) -> None: + """Test that enroll_node creates node and port.""" + mock_create_node.return_value = { + "uuid": "enrolled-uuid", + "name": "test-node", + "driver": "redfish", + } + + node_info = { + "name": "test-node", + "uuid": "vm-uuid", + "mac": "52:54:00:12:34:56", + "vcpus": 2, + "memory_mb": 4096, + "disk_gb": 20, + } + + client = IronicClient() + result = enroll_node(client, node_info, "http://sushy-tools:8000") + + assert result["uuid"] == "enrolled-uuid" + + # Verify node creation + mock_create_node.assert_called_once() + call_args = mock_create_node.call_args + assert call_args[1]["name"] == "test-node" + assert call_args[1]["driver"] == "redfish" + assert "redfish_address" in call_args[1]["driver_info"] + assert call_args[1]["driver_info"]["redfish_address"] == "http://sushy-tools:8000" + + # Verify port creation + mock_create_port.assert_called_once() + port_args = mock_create_port.call_args + assert port_args[1]["node_uuid"] == "enrolled-uuid" + assert port_args[1]["mac_address"] == "52:54:00:12:34:56" + + @patch("stackbox.core.enrollment.IronicClient.create_node") + def test_enroll_node_error_during_creation(self, mock_create_node: MagicMock) -> None: + """Test that enroll_node propagates errors from node creation.""" + mock_create_node.side_effect = RuntimeError("Node creation failed") + + node_info = { + "name": "test-node", + "uuid": "vm-uuid", + "mac": "52:54:00:12:34:56", + "vcpus": 2, + "memory_mb": 4096, + "disk_gb": 20, + } + + client = IronicClient() + + with pytest.raises(RuntimeError, match="Node creation failed"): + enroll_node(client, node_info, "http://sushy-tools:8000") + + +class TestVerifyPowerControl: + """Tests for verify_node_power_control helper function.""" + + @patch("time.sleep") + @patch("click.echo") + @patch("stackbox.core.enrollment.IronicClient.get_node_power_state") + @patch("stackbox.core.enrollment.IronicClient.set_node_power_state") + def test_verify_power_control_success( + self, + mock_set_state: MagicMock, + mock_get_state: MagicMock, + mock_echo: MagicMock, + mock_sleep: MagicMock, + ) -> None: + """Test that power control verification succeeds.""" + # First call returns "power on", second returns "power off" + mock_get_state.side_effect = ["power on", "power off"] + + client = IronicClient() + result = verify_node_power_control(client, "test-uuid") + + assert result is True + + # Verify power on was called + assert mock_set_state.call_count == 2 + mock_set_state.assert_any_call("test-uuid", "on") + mock_set_state.assert_any_call("test-uuid", "off") + + # Verify we waited between state changes + assert mock_sleep.call_count == 2 + + @patch("time.sleep") + @patch("click.echo") + @patch("stackbox.core.enrollment.IronicClient.get_node_power_state") + @patch("stackbox.core.enrollment.IronicClient.set_node_power_state") + def test_verify_power_control_fails_power_on( + self, + mock_set_state: MagicMock, + mock_get_state: MagicMock, + mock_echo: MagicMock, + mock_sleep: MagicMock, + ) -> None: + """Test that power control verification fails if power on doesn't work.""" + # Power state never changes to "power on" + mock_get_state.return_value = "power off" + + client = IronicClient() + result = verify_node_power_control(client, "test-uuid") + + assert result is False + mock_echo.assert_called() + + @patch("time.sleep") + @patch("click.echo") + @patch("stackbox.core.enrollment.IronicClient.set_node_power_state") + def test_verify_power_control_api_error( + self, + mock_set_state: MagicMock, + mock_echo: MagicMock, + mock_sleep: MagicMock, + ) -> None: + """Test that power control verification handles API errors.""" + mock_set_state.side_effect = requests.HTTPError("500 Server Error") + + client = IronicClient() + result = verify_node_power_control(client, "test-uuid") + + assert result is False + mock_echo.assert_called()