From c92fac1e5a739f97eb7c0345065e988283cb9f15 Mon Sep 17 00:00:00 2001 From: Lucas Alvares Gomes Date: Thu, 30 Apr 2015 14:46:54 +0100 Subject: [PATCH] Wake-On-Lan Power interface This patch implements a simple Wake-On-Lan power interface. For those that does not have any fancy hardware just old PCs at home. Wake-On-Lan is only capable of powering ON the machine, so it's recommended to use the DIB ramdisk for testing the deployments with it because it does a soft reboot on the machine at the end of the deployment. After the machine is unprovisioned, you'll have to manually power it off :-) This patch also doesn't implement SecureON password feature for Wake-On-Lan, I left a TODO in the code for those willing to implement it. Implements: blueprint wol-power-driver Change-Id: I6c0f98ef1cab1ebfb4a7e1d0aaae29672db1c5a4 --- ironic/common/exception.py | 4 + ironic/drivers/fake.py | 9 ++ ironic/drivers/modules/wol.py | 184 +++++++++++++++++++++++++++++ ironic/drivers/pxe.py | 17 +++ ironic/tests/drivers/test_wol.py | 194 +++++++++++++++++++++++++++++++ setup.cfg | 2 + 6 files changed, 410 insertions(+) create mode 100644 ironic/drivers/modules/wol.py create mode 100644 ironic/tests/drivers/test_wol.py diff --git a/ironic/common/exception.py b/ironic/common/exception.py index 75da2ef308..f4cfc4d83c 100644 --- a/ironic/common/exception.py +++ b/ironic/common/exception.py @@ -582,3 +582,7 @@ class UcsOperationError(IronicException): class UcsConnectionError(IronicException): message = _("Cisco UCS client: connection failed for node " "%(node)s. Reason: %(error)s") + + +class WolOperationError(IronicException): + pass diff --git a/ironic/drivers/fake.py b/ironic/drivers/fake.py index fed7b9e8d6..2b6586cc7e 100644 --- a/ironic/drivers/fake.py +++ b/ironic/drivers/fake.py @@ -46,6 +46,7 @@ from ironic.drivers.modules import ssh from ironic.drivers.modules.ucs import management as ucs_mgmt from ironic.drivers.modules.ucs import power as ucs_power from ironic.drivers.modules import virtualbox +from ironic.drivers.modules import wol from ironic.drivers import utils @@ -260,3 +261,11 @@ class FakeUcsDriver(base.BaseDriver): self.power = ucs_power.Power() self.deploy = fake.FakeDeploy() self.management = ucs_mgmt.UcsManagement() + + +class FakeWakeOnLanDriver(base.BaseDriver): + """Fake Wake-On-Lan driver.""" + + def __init__(self): + self.power = wol.WakeOnLanPower() + self.deploy = fake.FakeDeploy() diff --git a/ironic/drivers/modules/wol.py b/ironic/drivers/modules/wol.py new file mode 100644 index 0000000000..22e586abe1 --- /dev/null +++ b/ironic/drivers/modules/wol.py @@ -0,0 +1,184 @@ +# Copyright 2015 Red Hat, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +""" +Ironic Wake-On-Lan power manager. +""" + +import contextlib +import socket +import time + +from oslo_log import log + +from ironic.common import exception +from ironic.common.i18n import _ +from ironic.common.i18n import _LI +from ironic.common import states +from ironic.conductor import task_manager +from ironic.drivers import base + +LOG = log.getLogger(__name__) + +REQUIRED_PROPERTIES = {} +OPTIONAL_PROPERTIES = { + 'wol_host': _('Broadcast IP address; defaults to ' + '255.255.255.255. Optional.'), + 'wol_port': _("Destination port; defaults to 9. Optional."), +} +COMMON_PROPERTIES = REQUIRED_PROPERTIES.copy() +COMMON_PROPERTIES.update(OPTIONAL_PROPERTIES) + + +def _send_magic_packets(task, dest_host, dest_port): + """Create and send magic packets. + + Creates and sends a magic packet for each MAC address registered in + the Node. + + :param task: a TaskManager instance containing the node to act on. + :param dest_host: The broadcast to this IP address. + :param dest_port: The destination port. + :raises: WolOperationError if an error occur when connecting to the + host or sending the magic packets + + """ + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + s.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) + with contextlib.closing(s) as sock: + for port in task.ports: + address = port.address.replace(':', '') + + # TODO(lucasagomes): Implement sending the magic packets with + # SecureON password feature. If your NIC is capable of, you can + # set the password of your SecureON using the ethtool utility. + data = 'FFFFFFFFFFFF' + (address * 16) + packet = bytearray.fromhex(data) + + try: + sock.sendto(packet, (dest_host, dest_port)) + except socket.error as e: + msg = (_("Failed to send Wake-On-Lan magic packets to " + "node %(node)s port %(port)s. Error: %(error)s") % + {'node': task.node.uuid, 'port': port.address, + 'error': e}) + LOG.exception(msg) + raise exception.WolOperationError(msg) + + # let's not flood the network with broadcast packets + time.sleep(0.5) + + +def _parse_parameters(task): + driver_info = task.node.driver_info + host = driver_info.get('wol_host', '255.255.255.255') + port = driver_info.get('wol_port', 9) + try: + port = int(port) + except ValueError: + raise exception.InvalidParameterValue(_( + 'Wake-On-Lan port must be an integer')) + + if len(task.ports) < 1: + raise exception.MissingParameterValue(_( + 'Wake-On-Lan needs at least one port resource to be ' + 'registered in the node')) + + return {'host': host, 'port': port} + + +class WakeOnLanPower(base.PowerInterface): + """Wake-On-Lan Driver for Ironic + + This PowerManager class provides a mechanism for controlling power + state via Wake-On-Lan. + + """ + + def get_properties(self): + return COMMON_PROPERTIES + + def validate(self, task): + """Validate driver. + + :param task: a TaskManager instance containing the node to act on. + :raises: InvalidParameterValue if parameters are invalid. + :raises: MissingParameterValue if required parameters are missing. + + """ + _parse_parameters(task) + + def get_power_state(self, task): + """Not supported. Get the current power state of the task's node. + + This operation is not supported by the Wake-On-Lan driver. So + value returned will be from the database and may not reflect + the actual state of the system. + + :returns: POWER_OFF if power state is not set otherwise return + the node's power_state value from the database. + + """ + pstate = task.node.power_state + return states.POWER_OFF if pstate is states.NOSTATE else pstate + + @task_manager.require_exclusive_lock + def set_power_state(self, task, pstate): + """Wakes the task's node on power on. Powering off is not supported. + + Wakes the task's node on. Wake-On-Lan does not support powering + the task's node off so, just log it. + + :param task: a TaskManager instance containing the node to act on. + :param pstate: The desired power state, one of ironic.common.states + POWER_ON, POWER_OFF. + :raises: InvalidParameterValue if parameters are invalid. + :raises: MissingParameterValue if required parameters are missing. + :raises: WolOperationError if an error occur when sending the + magic packets + + """ + node = task.node + params = _parse_parameters(task) + if pstate == states.POWER_ON: + _send_magic_packets(task, params['host'], params['port']) + elif pstate == states.POWER_OFF: + LOG.info(_LI('Power off called for node %s. Wake-On-Lan does not ' + 'support this operation. Manual intervention ' + 'required to perform this action.'), node.uuid) + else: + raise exception.InvalidParameterValue(_( + "set_power_state called for Node %(node)s with invalid " + "power state %(pstate)s.") % {'node': node.uuid, + 'pstate': pstate}) + + @task_manager.require_exclusive_lock + def reboot(self, task): + """Not supported. Cycles the power to the task's node. + + This operation is not fully supported by the Wake-On-Lan + driver. So this method will just try to power the task's node on. + + :param task: a TaskManager instance containing the node to act on. + :raises: InvalidParameterValue if parameters are invalid. + :raises: MissingParameterValue if required parameters are missing. + :raises: WolOperationError if an error occur when sending the + magic packets + + """ + LOG.info(_LI('Reboot called for node %s. Wake-On-Lan does ' + 'not fully support this operation. Trying to ' + 'power on the node.'), task.node.uuid) + self.set_power_state(task, states.POWER_ON) diff --git a/ironic/drivers/pxe.py b/ironic/drivers/pxe.py index e42496aeb3..65e466e5b7 100644 --- a/ironic/drivers/pxe.py +++ b/ironic/drivers/pxe.py @@ -44,6 +44,7 @@ from ironic.drivers.modules import ssh from ironic.drivers.modules.ucs import management as ucs_mgmt from ironic.drivers.modules.ucs import power as ucs_power from ironic.drivers.modules import virtualbox +from ironic.drivers.modules import wol from ironic.drivers import utils @@ -309,3 +310,19 @@ class PXEAndUcsDriver(base.BaseDriver): self.deploy = pxe.PXEDeploy() self.management = ucs_mgmt.UcsManagement() self.vendor = pxe.VendorPassthru() + + +class PXEAndWakeOnLanDriver(base.BaseDriver): + """PXE + WakeOnLan driver. + + This driver implements the `core` functionality, combining + :class:`ironic.drivers.modules.wol.WakeOnLanPower` for power on + :class:`ironic.driver.modules.pxe.PXE` for image deployment. + Implementations are in those respective classes; + this class is merely the glue between them. + """ + + def __init__(self): + self.power = wol.WakeOnLanPower() + self.deploy = pxe.PXEDeploy() + self.vendor = pxe.VendorPassthru() diff --git a/ironic/tests/drivers/test_wol.py b/ironic/tests/drivers/test_wol.py new file mode 100644 index 0000000000..c98f1ab117 --- /dev/null +++ b/ironic/tests/drivers/test_wol.py @@ -0,0 +1,194 @@ +# Copyright 2015 Red Hat, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +"""Test class for Wake-On-Lan driver module.""" + +import socket +import time + +import mock +from oslo_utils import uuidutils + +from ironic.common import driver_factory +from ironic.common import exception +from ironic.common import states +from ironic.conductor import task_manager +from ironic.drivers.modules import wol +from ironic.tests.conductor import utils as mgr_utils +from ironic.tests.db import base as db_base +from ironic.tests.objects import utils as obj_utils + + +@mock.patch.object(time, 'sleep', lambda *_: None) +class WakeOnLanPrivateMethodTestCase(db_base.DbTestCase): + + def setUp(self): + super(WakeOnLanPrivateMethodTestCase, self).setUp() + mgr_utils.mock_the_extension_manager(driver='fake_wol') + self.driver = driver_factory.get_driver('fake_wol') + self.node = obj_utils.create_test_node(self.context, + driver='fake_wol') + self.port = obj_utils.create_test_port(self.context, + node_id=self.node.id) + + def test__parse_parameters(self): + with task_manager.acquire( + self.context, self.node.uuid, shared=True) as task: + params = wol._parse_parameters(task) + self.assertEqual('255.255.255.255', params['host']) + self.assertEqual(9, params['port']) + + def test__parse_parameters_non_default_params(self): + with task_manager.acquire( + self.context, self.node.uuid, shared=True) as task: + task.node.driver_info = {'wol_host': '1.2.3.4', + 'wol_port': 7} + params = wol._parse_parameters(task) + self.assertEqual('1.2.3.4', params['host']) + self.assertEqual(7, params['port']) + + def test__parse_parameters_no_ports_fail(self): + node = obj_utils.create_test_node( + self.context, + uuid=uuidutils.generate_uuid(), + driver='fake_wol') + with task_manager.acquire( + self.context, node.uuid, shared=True) as task: + self.assertRaises(exception.InvalidParameterValue, + wol._parse_parameters, task) + + @mock.patch.object(socket, 'socket', autospec=True, spec_set=True) + def test_send_magic_packets(self, mock_socket): + fake_socket = mock.Mock(spec=socket, spec_set=True) + mock_socket.return_value = fake_socket() + obj_utils.create_test_port(self.context, + uuid=uuidutils.generate_uuid(), + address='aa:bb:cc:dd:ee:ff', + node_id=self.node.id) + with task_manager.acquire( + self.context, self.node.uuid, shared=True) as task: + wol._send_magic_packets(task, '255.255.255.255', 9) + + expected_calls = [ + mock.call(), + mock.call().setsockopt(socket.SOL_SOCKET, + socket.SO_BROADCAST, 1), + mock.call().sendto(mock.ANY, ('255.255.255.255', 9)), + mock.call().sendto(mock.ANY, ('255.255.255.255', 9)), + mock.call().close()] + + fake_socket.assert_has_calls(expected_calls) + self.assertEqual(1, mock_socket.call_count) + + @mock.patch.object(socket, 'socket', autospec=True, spec_set=True) + def test_send_magic_packets_network_sendto_error(self, mock_socket): + fake_socket = mock.Mock(spec=socket, spec_set=True) + fake_socket.return_value.sendto.side_effect = socket.error('boom') + mock_socket.return_value = fake_socket() + with task_manager.acquire( + self.context, self.node.uuid, shared=True) as task: + self.assertRaises(exception.WolOperationError, + wol._send_magic_packets, + task, '255.255.255.255', 9) + self.assertEqual(1, mock_socket.call_count) + # assert sendt0() was invoked + fake_socket.return_value.sendto.assert_called_once_with( + mock.ANY, ('255.255.255.255', 9)) + + @mock.patch.object(socket, 'socket', autospec=True, spec_set=True) + def test_magic_packet_format(self, mock_socket): + fake_socket = mock.Mock(spec=socket, spec_set=True) + mock_socket.return_value = fake_socket() + with task_manager.acquire( + self.context, self.node.uuid, shared=True) as task: + wol._send_magic_packets(task, '255.255.255.255', 9) + + expct_packet = (b'\xff\xff\xff\xff\xff\xffRT\x00\xcf-1RT\x00' + b'\xcf-1RT\x00\xcf-1RT\x00\xcf-1RT\x00\xcf-1RT' + b'\x00\xcf-1RT\x00\xcf-1RT\x00\xcf-1RT\x00' + b'\xcf-1RT\x00\xcf-1RT\x00\xcf-1RT\x00\xcf-1RT' + b'\x00\xcf-1RT\x00\xcf-1RT\x00\xcf-1RT\x00\xcf-1') + mock_socket.return_value.sendto.assert_called_once_with( + expct_packet, ('255.255.255.255', 9)) + + +@mock.patch.object(time, 'sleep', lambda *_: None) +class WakeOnLanDriverTestCase(db_base.DbTestCase): + + def setUp(self): + super(WakeOnLanDriverTestCase, self).setUp() + mgr_utils.mock_the_extension_manager(driver='fake_wol') + self.driver = driver_factory.get_driver('fake_wol') + self.node = obj_utils.create_test_node(self.context, + driver='fake_wol') + self.port = obj_utils.create_test_port(self.context, + node_id=self.node.id) + + def test_get_properties(self): + expected = wol.COMMON_PROPERTIES + with task_manager.acquire( + self.context, self.node.uuid, shared=True) as task: + self.assertEqual(expected, task.driver.get_properties()) + + def test_get_power_state(self): + with task_manager.acquire( + self.context, self.node.uuid, shared=True) as task: + task.node.power_state = states.POWER_ON + pstate = task.driver.power.get_power_state(task) + self.assertEqual(states.POWER_ON, pstate) + + def test_get_power_state_nostate(self): + with task_manager.acquire( + self.context, self.node.uuid, shared=True) as task: + task.node.power_state = states.NOSTATE + pstate = task.driver.power.get_power_state(task) + self.assertEqual(states.POWER_OFF, pstate) + + @mock.patch.object(wol, '_send_magic_packets', autospec=True, + spec_set=True) + def test_set_power_state_power_on(self, mock_magic): + with task_manager.acquire(self.context, self.node.uuid) as task: + task.driver.power.set_power_state(task, states.POWER_ON) + mock_magic.assert_called_once_with(task, '255.255.255.255', 9) + + @mock.patch.object(wol.LOG, 'info', autospec=True, spec_set=True) + @mock.patch.object(wol, '_send_magic_packets', autospec=True, + spec_set=True) + def test_set_power_state_power_off(self, mock_magic, mock_log): + with task_manager.acquire(self.context, self.node.uuid) as task: + task.driver.power.set_power_state(task, states.POWER_OFF) + mock_log.assert_called_once_with(mock.ANY, self.node.uuid) + # assert magic packets weren't sent + self.assertFalse(mock_magic.called) + + @mock.patch.object(wol, '_send_magic_packets', autospec=True, + spec_set=True) + def test_set_power_state_power_fail(self, mock_magic): + with task_manager.acquire(self.context, self.node.uuid) as task: + self.assertRaises(exception.InvalidParameterValue, + task.driver.power.set_power_state, + task, 'wrong-state') + # assert magic packets weren't sent + self.assertFalse(mock_magic.called) + + @mock.patch.object(wol.LOG, 'info', autospec=True, spec_set=True) + @mock.patch.object(wol.WakeOnLanPower, 'set_power_state', autospec=True, + spec_set=True) + def test_reboot(self, mock_power, mock_log): + with task_manager.acquire(self.context, self.node.uuid) as task: + task.driver.power.reboot(task) + mock_log.assert_called_once_with(mock.ANY, self.node.uuid) + mock_power.assert_called_once_with(task.driver.power, task, + states.POWER_ON) diff --git a/setup.cfg b/setup.cfg index 6bf5966480..b14254b6e4 100644 --- a/setup.cfg +++ b/setup.cfg @@ -56,6 +56,7 @@ ironic.drivers = fake_amt = ironic.drivers.fake:FakeAMTDriver fake_msftocs = ironic.drivers.fake:FakeMSFTOCSDriver fake_ucs = ironic.drivers.fake:FakeUcsDriver + fake_wol = ironic.drivers.fake:FakeWakeOnLanDriver iscsi_ilo = ironic.drivers.ilo:IloVirtualMediaIscsiDriver pxe_ipmitool = ironic.drivers.pxe:PXEAndIPMIToolDriver pxe_ipminative = ironic.drivers.pxe:PXEAndIPMINativeDriver @@ -70,6 +71,7 @@ ironic.drivers = pxe_amt = ironic.drivers.pxe:PXEAndAMTDriver pxe_msftocs = ironic.drivers.pxe:PXEAndMSFTOCSDriver pxe_ucs = ironic.drivers.pxe:PXEAndUcsDriver + pxe_wol = ironic.drivers.pxe:PXEAndWakeOnLanDriver ironic.database.migration_backend = sqlalchemy = ironic.db.sqlalchemy.migration