diff --git a/releasenotes/notes/add-power-on-off-a77673d482568a8b.yaml b/releasenotes/notes/add-power-on-off-a77673d482568a8b.yaml new file mode 100644 index 0000000..0a3f172 --- /dev/null +++ b/releasenotes/notes/add-power-on-off-a77673d482568a8b.yaml @@ -0,0 +1,3 @@ +--- +features: + - Add action for compute node power on/off diff --git a/setup.cfg b/setup.cfg index 24ef857..7213f85 100644 --- a/setup.cfg +++ b/setup.cfg @@ -77,6 +77,7 @@ watcher_actions = sleep = watcher.applier.actions.sleep:Sleep change_nova_service_state = watcher.applier.actions.change_nova_service_state:ChangeNovaServiceState resize = watcher.applier.actions.resize:Resize + change_node_power_state = watcher.applier.actions.change_node_power_state:ChangeNodePowerState watcher_workflow_engines = taskflow = watcher.applier.workflow_engine.default:DefaultWorkFlowEngine diff --git a/watcher/applier/actions/change_node_power_state.py b/watcher/applier/actions/change_node_power_state.py new file mode 100644 index 0000000..431612b --- /dev/null +++ b/watcher/applier/actions/change_node_power_state.py @@ -0,0 +1,113 @@ +# -*- encoding: utf-8 -*- +# Copyright (c) 2017 ZTE +# +# Authors: Li Canwei +# +# 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. +# + +import enum +import six +import voluptuous + +from watcher._i18n import _ +from watcher.applier.actions import base +from watcher.common import exception + + +class NodeState(enum.Enum): + POWERON = 'on' + POWEROFF = 'off' + + +class ChangeNodePowerState(base.BaseAction): + """Compute node power on/off + + By using this action, you will be able to on/off the power of a + compute node. + + The action schema is:: + + schema = Schema({ + 'resource_id': str, + 'state': str, + }) + + The `resource_id` references a ironic node id (list of available + ironic node is returned by this command: ``ironic node-list``). + The `state` value should either be `on` or `off`. + """ + + STATE = 'state' + + @property + def schema(self): + return voluptuous.Schema({ + voluptuous.Required(self.RESOURCE_ID): + voluptuous.All( + voluptuous.Any(*six.string_types), + voluptuous.Length(min=1)), + voluptuous.Required(self.STATE): + voluptuous.Any(*[NodeState.POWERON.value, + NodeState.POWEROFF.value]), + }) + + @property + def node_uuid(self): + return self.resource_id + + @property + def state(self): + return self.input_parameters.get(self.STATE) + + def execute(self): + target_state = self.state + return self._node_manage_power(target_state) + + def revert(self): + if self.state == NodeState.POWERON.value: + target_state = NodeState.POWEROFF.value + elif self.state == NodeState.POWEROFF.value: + target_state = NodeState.POWERON.value + return self._node_manage_power(target_state) + + def _node_manage_power(self, state): + if state is None: + raise exception.IllegalArgumentException( + message=_("The target state is not defined")) + + result = False + ironic_client = self.osc.ironic() + nova_client = self.osc.nova() + if state == NodeState.POWEROFF.value: + node_info = ironic_client.node.get(self.node_uuid).to_dict() + compute_node_id = node_info['extra']['compute_node_id'] + compute_node = nova_client.hypervisors.get(compute_node_id) + compute_node = compute_node.to_dict() + if (compute_node['running_vms'] == 0): + result = ironic_client.node.set_power_state( + self.node_uuid, state) + else: + result = ironic_client.node.set_power_state(self.node_uuid, state) + return result + + def pre_condition(self): + pass + + def post_condition(self): + pass + + def get_description(self): + """Description of the action""" + return ("Compute node power on/off through ironic.") diff --git a/watcher/decision_engine/planner/weight.py b/watcher/decision_engine/planner/weight.py old mode 100755 new mode 100644 index 3af287e..24c707a --- a/watcher/decision_engine/planner/weight.py +++ b/watcher/decision_engine/planner/weight.py @@ -47,12 +47,13 @@ class WeightPlanner(base.BasePlanner): super(WeightPlanner, self).__init__(config) action_weights = { - 'turn_host_to_acpi_s3_state': 10, - 'resize': 20, - 'migrate': 30, - 'sleep': 40, - 'change_nova_service_state': 50, 'nop': 60, + 'change_nova_service_state': 50, + 'sleep': 40, + 'migrate': 30, + 'resize': 20, + 'turn_host_to_acpi_s3_state': 10, + 'change_node_power_state': 9, } parallelization = { @@ -62,6 +63,7 @@ class WeightPlanner(base.BasePlanner): 'sleep': 1, 'change_nova_service_state': 1, 'nop': 1, + 'change_node_power_state': 2, } @classmethod diff --git a/watcher/tests/applier/actions/test_change_node_power_state.py b/watcher/tests/applier/actions/test_change_node_power_state.py new file mode 100644 index 0000000..bca0f21 --- /dev/null +++ b/watcher/tests/applier/actions/test_change_node_power_state.py @@ -0,0 +1,147 @@ +# Copyright (c) 2017 ZTE +# +# 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. + +from __future__ import unicode_literals + +import mock +import voluptuous + +from watcher.applier.actions import base as baction +from watcher.applier.actions import change_node_power_state +from watcher.common import clients +from watcher.tests import base + +COMPUTE_NODE = "compute-1" + + +@mock.patch.object(clients.OpenStackClients, 'nova') +@mock.patch.object(clients.OpenStackClients, 'ironic') +class TestChangeNodePowerState(base.TestCase): + + def setUp(self): + super(TestChangeNodePowerState, self).setUp() + + self.input_parameters = { + baction.BaseAction.RESOURCE_ID: COMPUTE_NODE, + "state": change_node_power_state.NodeState.POWERON.value, + } + self.action = change_node_power_state.ChangeNodePowerState( + mock.Mock()) + self.action.input_parameters = self.input_parameters + + def test_parameters_down(self, mock_ironic, mock_nova): + self.action.input_parameters = { + baction.BaseAction.RESOURCE_ID: COMPUTE_NODE, + self.action.STATE: + change_node_power_state.NodeState.POWEROFF.value} + self.assertTrue(self.action.validate_parameters()) + + def test_parameters_up(self, mock_ironic, mock_nova): + self.action.input_parameters = { + baction.BaseAction.RESOURCE_ID: COMPUTE_NODE, + self.action.STATE: + change_node_power_state.NodeState.POWERON.value} + self.assertTrue(self.action.validate_parameters()) + + def test_parameters_exception_wrong_state(self, mock_ironic, mock_nova): + self.action.input_parameters = { + baction.BaseAction.RESOURCE_ID: COMPUTE_NODE, + self.action.STATE: 'error'} + exc = self.assertRaises( + voluptuous.Invalid, self.action.validate_parameters) + self.assertEqual( + [(['state'], voluptuous.ScalarInvalid)], + [([str(p) for p in e.path], type(e)) for e in exc.errors]) + + def test_parameters_resource_id_empty(self, mock_ironic, mock_nova): + self.action.input_parameters = { + self.action.STATE: + change_node_power_state.NodeState.POWERON.value, + } + exc = self.assertRaises( + voluptuous.Invalid, self.action.validate_parameters) + self.assertEqual( + [(['resource_id'], voluptuous.RequiredFieldInvalid)], + [([str(p) for p in e.path], type(e)) for e in exc.errors]) + + def test_parameters_applies_add_extra(self, mock_ironic, mock_nova): + self.action.input_parameters = {"extra": "failed"} + exc = self.assertRaises( + voluptuous.Invalid, self.action.validate_parameters) + self.assertEqual( + sorted([(['resource_id'], voluptuous.RequiredFieldInvalid), + (['state'], voluptuous.RequiredFieldInvalid), + (['extra'], voluptuous.Invalid)], + key=lambda x: str(x[0])), + sorted([([str(p) for p in e.path], type(e)) for e in exc.errors], + key=lambda x: str(x[0]))) + + def test_change_service_state_pre_condition(self, mock_ironic, mock_nova): + try: + self.action.pre_condition() + except Exception as exc: + self.fail(exc) + + def test_change_node_state_post_condition(self, mock_ironic, mock_nova): + try: + self.action.post_condition() + except Exception as exc: + self.fail(exc) + + def test_execute_node_service_state_with_poweron_target( + self, mock_ironic, mock_nova): + mock_irclient = mock_ironic.return_value + self.action.execute() + + mock_irclient.node.set_power_state.assert_called_once_with( + COMPUTE_NODE, change_node_power_state.NodeState.POWERON.value) + + def test_execute_change_node_state_with_poweroff_target( + self, mock_ironic, mock_nova): + mock_irclient = mock_ironic.return_value + mock_nvclient = mock_nova.return_value + mock_get = mock.MagicMock() + mock_get.to_dict.return_value = {'running_vms': 0} + mock_nvclient.hypervisors.get.return_value = mock_get + self.action.input_parameters["state"] = ( + change_node_power_state.NodeState.POWEROFF.value) + self.action.execute() + + mock_irclient.node.set_power_state.assert_called_once_with( + COMPUTE_NODE, change_node_power_state.NodeState.POWEROFF.value) + + def test_revert_change_node_state_with_poweron_target( + self, mock_ironic, mock_nova): + mock_irclient = mock_ironic.return_value + mock_nvclient = mock_nova.return_value + mock_get = mock.MagicMock() + mock_get.to_dict.return_value = {'running_vms': 0} + mock_nvclient.hypervisors.get.return_value = mock_get + self.action.input_parameters["state"] = ( + change_node_power_state.NodeState.POWERON.value) + self.action.revert() + + mock_irclient.node.set_power_state.assert_called_once_with( + COMPUTE_NODE, change_node_power_state.NodeState.POWEROFF.value) + + def test_revert_change_node_state_with_poweroff_target( + self, mock_ironic, mock_nova): + mock_irclient = mock_ironic.return_value + self.action.input_parameters["state"] = ( + change_node_power_state.NodeState.POWEROFF.value) + self.action.revert() + + mock_irclient.node.set_power_state.assert_called_once_with( + COMPUTE_NODE, change_node_power_state.NodeState.POWERON.value)