From c95ce4ec17aa7844f64e97d67cf66c017d656c47 Mon Sep 17 00:00:00 2001 From: Lucian Petrut Date: Thu, 5 Oct 2023 10:50:56 +0300 Subject: [PATCH] Add MAAS support At the moment, Watcher can use a single bare metal provisioning service: Openstack Ironic. We're now adding support for Canonical's MAAS service [1], which is commonly used along with Juju [2] to deploy Openstack. In order to do so, we're building a metal client abstraction, with concrete implementations for Ironic and MAAS. We'll pick the MAAS client if the MAAS url is provided, otherwise defaulting to Ironic. For now, we aren't updating the baremetal model collector since it doesn't seem to be used by any of the existing Watcher strategy implementations. [1] https://maas.io/docs [2] https://juju.is/docs Implements: blueprint maas-support Change-Id: I6861995598f6c542fa9c006131f10203f358e0a6 --- tox.ini | 1 + .../actions/change_node_power_state.py | 64 +++---- watcher/common/clients.py | 25 +++ watcher/common/metal_helper/__init__.py | 0 watcher/common/metal_helper/base.py | 81 +++++++++ watcher/common/metal_helper/constants.py | 23 +++ watcher/common/metal_helper/factory.py | 33 ++++ watcher/common/metal_helper/ironic.py | 94 ++++++++++ watcher/common/metal_helper/maas.py | 125 +++++++++++++ watcher/common/utils.py | 38 ++++ watcher/conf/__init__.py | 2 + watcher/conf/maas_client.py | 38 ++++ .../decision_engine/model/collector/ironic.py | 1 + watcher/decision_engine/model/model_root.py | 2 +- .../strategy/strategies/saving_energy.py | 62 ++++--- .../actions/test_change_node_power_state.py | 137 +++++++------- watcher/tests/common/metal_helper/__init__.py | 0 .../tests/common/metal_helper/test_base.py | 96 ++++++++++ .../tests/common/metal_helper/test_factory.py | 38 ++++ .../tests/common/metal_helper/test_ironic.py | 128 +++++++++++++ .../tests/common/metal_helper/test_maas.py | 126 +++++++++++++ watcher/tests/common/test_utils.py | 52 ++++++ .../decision_engine/fake_metal_helper.py | 47 +++++ .../strategy/strategies/test_saving_energy.py | 169 +++++++----------- 24 files changed, 1154 insertions(+), 228 deletions(-) create mode 100644 watcher/common/metal_helper/__init__.py create mode 100644 watcher/common/metal_helper/base.py create mode 100644 watcher/common/metal_helper/constants.py create mode 100644 watcher/common/metal_helper/factory.py create mode 100644 watcher/common/metal_helper/ironic.py create mode 100644 watcher/common/metal_helper/maas.py create mode 100644 watcher/conf/maas_client.py create mode 100644 watcher/tests/common/metal_helper/__init__.py create mode 100644 watcher/tests/common/metal_helper/test_base.py create mode 100644 watcher/tests/common/metal_helper/test_factory.py create mode 100644 watcher/tests/common/metal_helper/test_ironic.py create mode 100644 watcher/tests/common/metal_helper/test_maas.py create mode 100644 watcher/tests/common/test_utils.py create mode 100644 watcher/tests/decision_engine/fake_metal_helper.py diff --git a/tox.ini b/tox.ini index e55204433..47b8eadc5 100644 --- a/tox.ini +++ b/tox.ini @@ -14,6 +14,7 @@ setenv = deps = -r{toxinidir}/test-requirements.txt -r{toxinidir}/requirements.txt + python-libmaas>=0.6.8 commands = rm -f .testrepository/times.dbm find . -type f -name "*.py[c|o]" -delete diff --git a/watcher/applier/actions/change_node_power_state.py b/watcher/applier/actions/change_node_power_state.py index 89e454b3d..6755343bd 100644 --- a/watcher/applier/actions/change_node_power_state.py +++ b/watcher/applier/actions/change_node_power_state.py @@ -17,17 +17,17 @@ # limitations under the License. # -import enum import time +from oslo_log import log + from watcher._i18n import _ from watcher.applier.actions import base from watcher.common import exception +from watcher.common.metal_helper import constants as metal_constants +from watcher.common.metal_helper import factory as metal_helper_factory - -class NodeState(enum.Enum): - POWERON = 'on' - POWEROFF = 'off' +LOG = log.getLogger(__name__) class ChangeNodePowerState(base.BaseAction): @@ -43,8 +43,8 @@ class ChangeNodePowerState(base.BaseAction): 'state': str, }) - The `resource_id` references a ironic node id (list of available - ironic node is returned by this command: ``ironic node-list``). + The `resource_id` references a baremetal node id (list of available + ironic nodes is returned by this command: ``ironic node-list``). The `state` value should either be `on` or `off`. """ @@ -65,8 +65,8 @@ class ChangeNodePowerState(base.BaseAction): }, 'state': { 'type': 'string', - 'enum': [NodeState.POWERON.value, - NodeState.POWEROFF.value] + 'enum': [metal_constants.PowerState.ON.value, + metal_constants.PowerState.OFF.value] } }, 'required': ['resource_id', 'state'], @@ -86,10 +86,10 @@ class ChangeNodePowerState(base.BaseAction): 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 + if self.state == metal_constants.PowerState.ON.value: + target_state = metal_constants.PowerState.OFF.value + elif self.state == metal_constants.PowerState.OFF.value: + target_state = metal_constants.PowerState.ON.value return self._node_manage_power(target_state) def _node_manage_power(self, state, retry=60): @@ -97,30 +97,32 @@ class ChangeNodePowerState(base.BaseAction): raise exception.IllegalArgumentException( message=_("The target state is not defined")) - ironic_client = self.osc.ironic() - nova_client = self.osc.nova() - current_state = ironic_client.node.get(self.node_uuid).power_state - # power state: 'power on' or 'power off', if current node state - # is the same as state, just return True - if state in current_state: + metal_helper = metal_helper_factory.get_helper(self.osc) + node = metal_helper.get_node(self.node_uuid) + current_state = node.get_power_state() + + if state == current_state.value: return True - 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 state == metal_constants.PowerState.OFF.value: + compute_node = node.get_hypervisor_node().to_dict() if (compute_node['running_vms'] == 0): - ironic_client.node.set_power_state( - self.node_uuid, state) + node.set_power_state(state) + else: + LOG.warning( + "Compute node %s has %s running vms and will " + "NOT be shut off.", + compute_node["hypervisor_hostname"], + compute_node['running_vms']) + return False else: - ironic_client.node.set_power_state(self.node_uuid, state) + node.set_power_state(state) - ironic_node = ironic_client.node.get(self.node_uuid) - while ironic_node.power_state == current_state and retry: + node = metal_helper.get_node(self.node_uuid) + while node.get_power_state() == current_state and retry: time.sleep(10) retry -= 1 - ironic_node = ironic_client.node.get(self.node_uuid) + node = metal_helper.get_node(self.node_uuid) if retry > 0: return True else: @@ -134,4 +136,4 @@ class ChangeNodePowerState(base.BaseAction): def get_description(self): """Description of the action""" - return ("Compute node power on/off through ironic.") + return ("Compute node power on/off through Ironic or MaaS.") diff --git a/watcher/common/clients.py b/watcher/common/clients.py index f73adc78f..9186c674e 100755 --- a/watcher/common/clients.py +++ b/watcher/common/clients.py @@ -25,6 +25,7 @@ from novaclient import api_versions as nova_api_versions from novaclient import client as nvclient from watcher.common import exception +from watcher.common import utils try: from ceilometerclient import client as ceclient @@ -32,6 +33,12 @@ try: except ImportError: HAS_CEILCLIENT = False +try: + from maas import client as maas_client +except ImportError: + maas_client = None + + CONF = cfg.CONF _CLIENTS_AUTH_GROUP = 'watcher_clients_auth' @@ -74,6 +81,7 @@ class OpenStackClients(object): self._monasca = None self._neutron = None self._ironic = None + self._maas = None self._placement = None def _get_keystone_session(self): @@ -265,6 +273,23 @@ class OpenStackClients(object): session=self.session) return self._ironic + def maas(self): + if self._maas: + return self._maas + + if not maas_client: + raise exception.UnsupportedError( + "MAAS client unavailable. Please install python-libmaas.") + + url = self._get_client_option('maas', 'url') + api_key = self._get_client_option('maas', 'api_key') + timeout = self._get_client_option('maas', 'timeout') + self._maas = utils.async_compat_call( + maas_client.connect, + url, apikey=api_key, + timeout=timeout) + return self._maas + @exception.wrap_keystone_exception def placement(self): if self._placement: diff --git a/watcher/common/metal_helper/__init__.py b/watcher/common/metal_helper/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/watcher/common/metal_helper/base.py b/watcher/common/metal_helper/base.py new file mode 100644 index 000000000..9f452ff36 --- /dev/null +++ b/watcher/common/metal_helper/base.py @@ -0,0 +1,81 @@ +# Copyright 2023 Cloudbase Solutions +# 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. + +import abc + +from watcher.common import exception +from watcher.common.metal_helper import constants as metal_constants + + +class BaseMetalNode(abc.ABC): + hv_up_when_powered_off = False + + def __init__(self, nova_node=None): + self._nova_node = nova_node + + def get_hypervisor_node(self): + if not self._nova_node: + raise exception.Invalid(message="No associated hypervisor.") + return self._nova_node + + def get_hypervisor_hostname(self): + return self.get_hypervisor_node().hypervisor_hostname + + @abc.abstractmethod + def get_power_state(self): + # TODO(lpetrut): document the following methods + pass + + @abc.abstractmethod + def get_id(self): + """Return the node id provided by the bare metal service.""" + pass + + @abc.abstractmethod + def power_on(self): + pass + + @abc.abstractmethod + def power_off(self): + pass + + def set_power_state(self, state): + state = metal_constants.PowerState(state) + if state == metal_constants.PowerState.ON: + self.power_on() + elif state == metal_constants.PowerState.OFF: + self.power_off() + else: + raise exception.UnsupportedActionType( + "Cannot set power state: %s" % state) + + +class BaseMetalHelper(abc.ABC): + def __init__(self, osc): + self._osc = osc + + @property + def nova_client(self): + if not getattr(self, "_nova_client", None): + self._nova_client = self._osc.nova() + return self._nova_client + + @abc.abstractmethod + def list_compute_nodes(self): + pass + + @abc.abstractmethod + def get_node(self, node_id): + pass diff --git a/watcher/common/metal_helper/constants.py b/watcher/common/metal_helper/constants.py new file mode 100644 index 000000000..0c64773f5 --- /dev/null +++ b/watcher/common/metal_helper/constants.py @@ -0,0 +1,23 @@ +# Copyright 2023 Cloudbase Solutions +# 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. + +import enum + + +class PowerState(str, enum.Enum): + ON = "on" + OFF = "off" + UNKNOWN = "unknown" + ERROR = "error" diff --git a/watcher/common/metal_helper/factory.py b/watcher/common/metal_helper/factory.py new file mode 100644 index 000000000..fefe79788 --- /dev/null +++ b/watcher/common/metal_helper/factory.py @@ -0,0 +1,33 @@ +# Copyright 2023 Cloudbase Solutions +# 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. + +from oslo_config import cfg + +from watcher.common import clients +from watcher.common.metal_helper import ironic +from watcher.common.metal_helper import maas + +CONF = cfg.CONF + + +def get_helper(osc=None): + # TODO(lpetrut): consider caching this client. + if not osc: + osc = clients.OpenStackClients() + + if CONF.maas_client.url: + return maas.MaasHelper(osc) + else: + return ironic.IronicHelper(osc) diff --git a/watcher/common/metal_helper/ironic.py b/watcher/common/metal_helper/ironic.py new file mode 100644 index 000000000..d4cdda877 --- /dev/null +++ b/watcher/common/metal_helper/ironic.py @@ -0,0 +1,94 @@ +# Copyright 2023 Cloudbase Solutions +# 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. + +from oslo_log import log + +from watcher.common.metal_helper import base +from watcher.common.metal_helper import constants as metal_constants + +LOG = log.getLogger(__name__) + +POWER_STATES_MAP = { + 'power on': metal_constants.PowerState.ON, + 'power off': metal_constants.PowerState.OFF, + # For now, we only use ON/OFF states + 'rebooting': metal_constants.PowerState.ON, + 'soft power off': metal_constants.PowerState.OFF, + 'soft reboot': metal_constants.PowerState.ON, +} + + +class IronicNode(base.BaseMetalNode): + hv_up_when_powered_off = True + + def __init__(self, ironic_node, nova_node, ironic_client): + super().__init__(nova_node) + + self._ironic_client = ironic_client + self._ironic_node = ironic_node + + def get_power_state(self): + return POWER_STATES_MAP.get(self._ironic_node.power_state, + metal_constants.PowerState.UNKNOWN) + + def get_id(self): + return self._ironic_node.uuid + + def power_on(self): + self._ironic_client.node.set_power_state(self.get_id(), "on") + + def power_off(self): + self._ironic_client.node.set_power_state(self.get_id(), "off") + + +class IronicHelper(base.BaseMetalHelper): + @property + def _client(self): + if not getattr(self, "_cached_client", None): + self._cached_client = self._osc.ironic() + return self._cached_client + + def list_compute_nodes(self): + out_list = [] + # TODO(lpetrut): consider using "detailed=True" instead of making + # an additional GET request per node + node_list = self._client.node.list() + + for node in node_list: + node_info = self._client.node.get(node.uuid) + hypervisor_id = node_info.extra.get('compute_node_id', None) + if hypervisor_id is None: + LOG.warning('Cannot find compute_node_id in extra ' + 'of ironic node %s', node.uuid) + continue + + hypervisor_node = self.nova_client.hypervisors.get(hypervisor_id) + if hypervisor_node is None: + LOG.warning('Cannot find hypervisor %s', hypervisor_id) + continue + + out_node = IronicNode(node, hypervisor_node, self._client) + out_list.append(out_node) + + return out_list + + def get_node(self, node_id): + ironic_node = self._client.node.get(node_id) + compute_node_id = ironic_node.extra.get('compute_node_id') + if compute_node_id: + compute_node = self.nova_client.hypervisors.get(compute_node_id) + else: + compute_node = None + return IronicNode(ironic_node, compute_node, self._client) diff --git a/watcher/common/metal_helper/maas.py b/watcher/common/metal_helper/maas.py new file mode 100644 index 000000000..e5b9fa84b --- /dev/null +++ b/watcher/common/metal_helper/maas.py @@ -0,0 +1,125 @@ +# Copyright 2023 Cloudbase Solutions +# 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. + +from oslo_config import cfg +from oslo_log import log + +from watcher.common import exception +from watcher.common.metal_helper import base +from watcher.common.metal_helper import constants as metal_constants +from watcher.common import utils + +CONF = cfg.CONF +LOG = log.getLogger(__name__) + +try: + from maas.client import enum as maas_enum +except ImportError: + maas_enum = None + + +class MaasNode(base.BaseMetalNode): + hv_up_when_powered_off = False + + def __init__(self, maas_node, nova_node, maas_client): + super().__init__(nova_node) + + self._maas_client = maas_client + self._maas_node = maas_node + + def get_power_state(self): + maas_state = utils.async_compat_call( + self._maas_node.query_power_state, + timeout=CONF.maas_client.timeout) + + # python-libmaas may not be available, so we'll avoid a global + # variable. + power_states_map = { + maas_enum.PowerState.ON: metal_constants.PowerState.ON, + maas_enum.PowerState.OFF: metal_constants.PowerState.OFF, + maas_enum.PowerState.ERROR: metal_constants.PowerState.ERROR, + maas_enum.PowerState.UNKNOWN: metal_constants.PowerState.UNKNOWN, + } + return power_states_map.get(maas_state, + metal_constants.PowerState.UNKNOWN) + + def get_id(self): + return self._maas_node.system_id + + def power_on(self): + LOG.info("Powering on MAAS node: %s %s", + self._maas_node.fqdn, + self._maas_node.system_id) + utils.async_compat_call( + self._maas_node.power_on, + timeout=CONF.maas_client.timeout) + + def power_off(self): + LOG.info("Powering off MAAS node: %s %s", + self._maas_node.fqdn, + self._maas_node.system_id) + utils.async_compat_call( + self._maas_node.power_off, + timeout=CONF.maas_client.timeout) + + +class MaasHelper(base.BaseMetalHelper): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + if not maas_enum: + raise exception.UnsupportedError( + "MAAS client unavailable. Please install python-libmaas.") + + @property + def _client(self): + if not getattr(self, "_cached_client", None): + self._cached_client = self._osc.maas() + return self._cached_client + + def list_compute_nodes(self): + out_list = [] + node_list = utils.async_compat_call( + self._client.machines.list, + timeout=CONF.maas_client.timeout) + + compute_nodes = self.nova_client.hypervisors.list() + compute_node_map = dict() + for compute_node in compute_nodes: + compute_node_map[compute_node.hypervisor_hostname] = compute_node + + for node in node_list: + hypervisor_node = compute_node_map.get(node.fqdn) + if not hypervisor_node: + LOG.info('Cannot find hypervisor %s', node.fqdn) + continue + + out_node = MaasNode(node, hypervisor_node, self._client) + out_list.append(out_node) + + return out_list + + def _get_compute_node_by_hostname(self, hostname): + compute_nodes = self.nova_client.hypervisors.search( + hostname, detailed=True) + for compute_node in compute_nodes: + if compute_node.hypervisor_hostname == hostname: + return compute_node + + def get_node(self, node_id): + maas_node = utils.async_compat_call( + self._client.machines.get, node_id, + timeout=CONF.maas_client.timeout) + compute_node = self._get_compute_node_by_hostname(maas_node.fqdn) + return MaasNode(maas_node, compute_node, self._client) diff --git a/watcher/common/utils.py b/watcher/common/utils.py index 645b73965..5780e81f3 100644 --- a/watcher/common/utils.py +++ b/watcher/common/utils.py @@ -16,12 +16,16 @@ """Utilities and helper functions.""" +import asyncio import datetime +import inspect import random import re import string from croniter import croniter +import eventlet +from eventlet import tpool from jsonschema import validators from oslo_config import cfg @@ -162,3 +166,37 @@ Draft4Validator = validators.Draft4Validator def random_string(n): return ''.join([random.choice( string.ascii_letters + string.digits) for i in range(n)]) + + +# Some clients (e.g. MAAS) use asyncio, which isn't compatible with Eventlet. +# As a workaround, we're delegating such calls to a native thread. +def async_compat_call(f, *args, **kwargs): + timeout = kwargs.pop('timeout', None) + + async def async_wrapper(): + ret = f(*args, **kwargs) + if inspect.isawaitable(ret): + return await asyncio.wait_for(ret, timeout) + return ret + + def tpool_wrapper(): + # This will run in a separate native thread. Ideally, there should be + # a single thread permanently running an asyncio loop, but for + # convenience we'll use eventlet.tpool, which leverages a thread pool. + # + # That being considered, we're setting up a temporary asyncio loop to + # handle this call. + loop = asyncio.new_event_loop() + try: + asyncio.set_event_loop(loop) + return loop.run_until_complete(async_wrapper()) + finally: + loop.close() + + # We'll use eventlet timeouts as an extra precaution and asyncio timeouts + # to avoid lingering threads. For consistency, we'll convert eventlet + # timeout exceptions to asyncio timeout errors. + with eventlet.timeout.Timeout( + seconds=timeout, + exception=asyncio.TimeoutError("Timeout: %ss" % timeout)): + return tpool.execute(tpool_wrapper) diff --git a/watcher/conf/__init__.py b/watcher/conf/__init__.py index 3bdc120a8..d22653109 100755 --- a/watcher/conf/__init__.py +++ b/watcher/conf/__init__.py @@ -35,6 +35,7 @@ from watcher.conf import grafana_client from watcher.conf import grafana_translators from watcher.conf import ironic_client from watcher.conf import keystone_client +from watcher.conf import maas_client from watcher.conf import monasca_client from watcher.conf import neutron_client from watcher.conf import nova_client @@ -54,6 +55,7 @@ db.register_opts(CONF) planner.register_opts(CONF) applier.register_opts(CONF) decision_engine.register_opts(CONF) +maas_client.register_opts(CONF) monasca_client.register_opts(CONF) nova_client.register_opts(CONF) glance_client.register_opts(CONF) diff --git a/watcher/conf/maas_client.py b/watcher/conf/maas_client.py new file mode 100644 index 000000000..49fbe18b7 --- /dev/null +++ b/watcher/conf/maas_client.py @@ -0,0 +1,38 @@ +# Copyright 2023 Cloudbase Solutions +# 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. + + +from oslo_config import cfg + +maas_client = cfg.OptGroup(name='maas_client', + title='Configuration Options for MaaS') + +MAAS_CLIENT_OPTS = [ + cfg.StrOpt('url', + help='MaaS URL, example: http://1.2.3.4:5240/MAAS'), + cfg.StrOpt('api_key', + help='MaaS API authentication key.'), + cfg.IntOpt('timeout', + default=60, + help='MaaS client operation timeout in seconds.')] + + +def register_opts(conf): + conf.register_group(maas_client) + conf.register_opts(MAAS_CLIENT_OPTS, group=maas_client) + + +def list_opts(): + return [(maas_client, MAAS_CLIENT_OPTS)] diff --git a/watcher/decision_engine/model/collector/ironic.py b/watcher/decision_engine/model/collector/ironic.py index 1be079937..522602290 100644 --- a/watcher/decision_engine/model/collector/ironic.py +++ b/watcher/decision_engine/model/collector/ironic.py @@ -81,6 +81,7 @@ class BareMetalModelBuilder(base.BaseModelBuilder): def __init__(self, osc): self.osc = osc self.model = model_root.BaremetalModelRoot() + # TODO(lpetrut): add MAAS support self.ironic_helper = ironic_helper.IronicHelper(osc=self.osc) def add_ironic_node(self, node): diff --git a/watcher/decision_engine/model/model_root.py b/watcher/decision_engine/model/model_root.py index 254271f11..a38e53314 100644 --- a/watcher/decision_engine/model/model_root.py +++ b/watcher/decision_engine/model/model_root.py @@ -157,7 +157,7 @@ class ModelRoot(nx.DiGraph, base.Model): if node_list: return node_list[0] else: - raise exception.ComputeResourceNotFound + raise exception.ComputeNodeNotFound(name=name) except exception.ComputeResourceNotFound: raise exception.ComputeNodeNotFound(name=name) diff --git a/watcher/decision_engine/strategy/strategies/saving_energy.py b/watcher/decision_engine/strategy/strategies/saving_energy.py index 3dfcddf89..6c706b06d 100644 --- a/watcher/decision_engine/strategy/strategies/saving_energy.py +++ b/watcher/decision_engine/strategy/strategies/saving_energy.py @@ -23,6 +23,8 @@ from oslo_log import log from watcher._i18n import _ from watcher.common import exception +from watcher.common.metal_helper import constants as metal_constants +from watcher.common.metal_helper import factory as metal_helper_factory from watcher.decision_engine.strategy.strategies import base LOG = log.getLogger(__name__) @@ -81,7 +83,7 @@ class SavingEnergy(base.SavingEnergyBaseStrategy): def __init__(self, config, osc=None): super(SavingEnergy, self).__init__(config, osc) - self._ironic_client = None + self._metal_helper = None self._nova_client = None self.with_vms_node_pool = [] @@ -91,10 +93,10 @@ class SavingEnergy(base.SavingEnergyBaseStrategy): self.min_free_hosts_num = 1 @property - def ironic_client(self): - if not self._ironic_client: - self._ironic_client = self.osc.ironic() - return self._ironic_client + def metal_helper(self): + if not self._metal_helper: + self._metal_helper = metal_helper_factory.get_helper(self.osc) + return self._metal_helper @property def nova_client(self): @@ -149,10 +151,10 @@ class SavingEnergy(base.SavingEnergyBaseStrategy): :return: None """ params = {'state': state, - 'resource_name': node.hostname} + 'resource_name': node.get_hypervisor_hostname()} self.solution.add_action( action_type='change_node_power_state', - resource_id=node.uuid, + resource_id=node.get_id(), input_parameters=params) def get_hosts_pool(self): @@ -162,36 +164,36 @@ class SavingEnergy(base.SavingEnergyBaseStrategy): """ - node_list = self.ironic_client.node.list() + node_list = self.metal_helper.list_compute_nodes() for node in node_list: - node_info = self.ironic_client.node.get(node.uuid) - hypervisor_id = node_info.extra.get('compute_node_id', None) - if hypervisor_id is None: - LOG.warning(('Cannot find compute_node_id in extra ' - 'of ironic node %s'), node.uuid) - continue - hypervisor_node = self.nova_client.hypervisors.get(hypervisor_id) - if hypervisor_node is None: - LOG.warning(('Cannot find hypervisor %s'), hypervisor_id) - continue - node.hostname = hypervisor_node.hypervisor_hostname - hypervisor_node = hypervisor_node.to_dict() + hypervisor_node = node.get_hypervisor_node().to_dict() + compute_service = hypervisor_node.get('service', None) host_name = compute_service.get('host') + LOG.debug("Found hypervisor: %s", hypervisor_node) try: self.compute_model.get_node_by_name(host_name) except exception.ComputeNodeNotFound: + LOG.info("The compute model does not contain the host: %s", + host_name) continue - if not (hypervisor_node.get('state') == 'up'): - """filter nodes that are not in 'up' state""" + if (node.hv_up_when_powered_off and + hypervisor_node.get('state') != 'up'): + # filter nodes that are not in 'up' state + LOG.info("Ignoring node that isn't in 'up' state: %s", + host_name) continue else: if (hypervisor_node['running_vms'] == 0): - if (node_info.power_state == 'power on'): + power_state = node.get_power_state() + if power_state == metal_constants.PowerState.ON: self.free_poweron_node_pool.append(node) - elif (node_info.power_state == 'power off'): + elif power_state == metal_constants.PowerState.OFF: self.free_poweroff_node_pool.append(node) + else: + LOG.info("Ignoring node %s, unknown state: %s", + node, power_state) else: self.with_vms_node_pool.append(node) @@ -202,17 +204,21 @@ class SavingEnergy(base.SavingEnergyBaseStrategy): self.min_free_hosts_num))) len_poweron = len(self.free_poweron_node_pool) len_poweroff = len(self.free_poweroff_node_pool) + LOG.debug("need_poweron: %s, len_poweron: %s, len_poweroff: %s", + need_poweron, len_poweron, len_poweroff) if len_poweron > need_poweron: for node in random.sample(self.free_poweron_node_pool, (len_poweron - need_poweron)): - self.add_action_poweronoff_node(node, 'off') - LOG.info("power off %s", node.uuid) + self.add_action_poweronoff_node(node, + metal_constants.PowerState.OFF) + LOG.info("power off %s", node.get_id()) elif len_poweron < need_poweron: diff = need_poweron - len_poweron for node in random.sample(self.free_poweroff_node_pool, min(len_poweroff, diff)): - self.add_action_poweronoff_node(node, 'on') - LOG.info("power on %s", node.uuid) + self.add_action_poweronoff_node(node, + metal_constants.PowerState.ON) + LOG.info("power on %s", node.get_id()) def pre_execute(self): self._pre_execute() diff --git a/watcher/tests/applier/actions/test_change_node_power_state.py b/watcher/tests/applier/actions/test_change_node_power_state.py index aeadeeb10..8444551c5 100644 --- a/watcher/tests/applier/actions/test_change_node_power_state.py +++ b/watcher/tests/applier/actions/test_change_node_power_state.py @@ -19,134 +19,151 @@ import jsonschema from watcher.applier.actions import base as baction from watcher.applier.actions import change_node_power_state -from watcher.common import clients +from watcher.common.metal_helper import constants as m_constants +from watcher.common.metal_helper import factory as m_helper_factory from watcher.tests import base +from watcher.tests.decision_engine import fake_metal_helper 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() + p_m_factory = mock.patch.object(m_helper_factory, 'get_helper') + m_factory = p_m_factory.start() + self._metal_helper = m_factory.return_value + self.addCleanup(p_m_factory.stop) + + # Let's avoid unnecessary sleep calls while running the test. + p_sleep = mock.patch('time.sleep') + p_sleep.start() + self.addCleanup(p_sleep.stop) + self.input_parameters = { baction.BaseAction.RESOURCE_ID: COMPUTE_NODE, - "state": change_node_power_state.NodeState.POWERON.value, + "state": m_constants.PowerState.ON.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): + def test_parameters_down(self): self.action.input_parameters = { baction.BaseAction.RESOURCE_ID: COMPUTE_NODE, self.action.STATE: - change_node_power_state.NodeState.POWEROFF.value} + m_constants.PowerState.OFF.value} self.assertTrue(self.action.validate_parameters()) - def test_parameters_up(self, mock_ironic, mock_nova): + def test_parameters_up(self): self.action.input_parameters = { baction.BaseAction.RESOURCE_ID: COMPUTE_NODE, self.action.STATE: - change_node_power_state.NodeState.POWERON.value} + m_constants.PowerState.ON.value} self.assertTrue(self.action.validate_parameters()) - def test_parameters_exception_wrong_state(self, mock_ironic, mock_nova): + def test_parameters_exception_wrong_state(self): self.action.input_parameters = { baction.BaseAction.RESOURCE_ID: COMPUTE_NODE, self.action.STATE: 'error'} self.assertRaises(jsonschema.ValidationError, self.action.validate_parameters) - def test_parameters_resource_id_empty(self, mock_ironic, mock_nova): + def test_parameters_resource_id_empty(self): self.action.input_parameters = { self.action.STATE: - change_node_power_state.NodeState.POWERON.value, + m_constants.PowerState.ON.value, } self.assertRaises(jsonschema.ValidationError, self.action.validate_parameters) - def test_parameters_applies_add_extra(self, mock_ironic, mock_nova): + def test_parameters_applies_add_extra(self): self.action.input_parameters = {"extra": "failed"} self.assertRaises(jsonschema.ValidationError, self.action.validate_parameters) - def test_change_service_state_pre_condition(self, mock_ironic, mock_nova): + def test_change_service_state_pre_condition(self): try: self.action.pre_condition() except Exception as exc: self.fail(exc) - def test_change_node_state_post_condition(self, mock_ironic, mock_nova): + def test_change_node_state_post_condition(self): 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 + def test_execute_node_service_state_with_poweron_target(self): self.action.input_parameters["state"] = ( - change_node_power_state.NodeState.POWERON.value) - mock_irclient.node.get.side_effect = [ - mock.MagicMock(power_state='power off'), - mock.MagicMock(power_state='power on')] + m_constants.PowerState.ON.value) + mock_nodes = [ + fake_metal_helper.get_mock_metal_node( + power_state=m_constants.PowerState.OFF), + fake_metal_helper.get_mock_metal_node( + power_state=m_constants.PowerState.ON) + ] + self._metal_helper.get_node.side_effect = mock_nodes result = self.action.execute() self.assertTrue(result) - mock_irclient.node.set_power_state.assert_called_once_with( - COMPUTE_NODE, change_node_power_state.NodeState.POWERON.value) + mock_nodes[0].set_power_state.assert_called_once_with( + m_constants.PowerState.ON.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 + def test_execute_change_node_state_with_poweroff_target(self): self.action.input_parameters["state"] = ( - change_node_power_state.NodeState.POWEROFF.value) - mock_irclient.node.get.side_effect = [ - mock.MagicMock(power_state='power on'), - mock.MagicMock(power_state='power on'), - mock.MagicMock(power_state='power off')] + m_constants.PowerState.OFF.value) + + mock_nodes = [ + fake_metal_helper.get_mock_metal_node( + power_state=m_constants.PowerState.ON), + fake_metal_helper.get_mock_metal_node( + power_state=m_constants.PowerState.ON), + fake_metal_helper.get_mock_metal_node( + power_state=m_constants.PowerState.OFF) + ] + self._metal_helper.get_node.side_effect = mock_nodes + result = self.action.execute() self.assertTrue(result) - mock_irclient.node.set_power_state.assert_called_once_with( - COMPUTE_NODE, change_node_power_state.NodeState.POWEROFF.value) + mock_nodes[0].set_power_state.assert_called_once_with( + m_constants.PowerState.OFF.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 + def test_revert_change_node_state_with_poweron_target(self): self.action.input_parameters["state"] = ( - change_node_power_state.NodeState.POWERON.value) - mock_irclient.node.get.side_effect = [ - mock.MagicMock(power_state='power on'), - mock.MagicMock(power_state='power on'), - mock.MagicMock(power_state='power off')] + m_constants.PowerState.ON.value) + + mock_nodes = [ + fake_metal_helper.get_mock_metal_node( + power_state=m_constants.PowerState.ON), + fake_metal_helper.get_mock_metal_node( + power_state=m_constants.PowerState.ON), + fake_metal_helper.get_mock_metal_node( + power_state=m_constants.PowerState.OFF) + ] + self._metal_helper.get_node.side_effect = mock_nodes + self.action.revert() - mock_irclient.node.set_power_state.assert_called_once_with( - COMPUTE_NODE, change_node_power_state.NodeState.POWEROFF.value) + mock_nodes[0].set_power_state.assert_called_once_with( + m_constants.PowerState.OFF.value) - def test_revert_change_node_state_with_poweroff_target( - self, mock_ironic, mock_nova): - mock_irclient = mock_ironic.return_value + def test_revert_change_node_state_with_poweroff_target(self): self.action.input_parameters["state"] = ( - change_node_power_state.NodeState.POWEROFF.value) - mock_irclient.node.get.side_effect = [ - mock.MagicMock(power_state='power off'), - mock.MagicMock(power_state='power on')] + m_constants.PowerState.OFF.value) + mock_nodes = [ + fake_metal_helper.get_mock_metal_node( + power_state=m_constants.PowerState.OFF), + fake_metal_helper.get_mock_metal_node( + power_state=m_constants.PowerState.ON) + ] + self._metal_helper.get_node.side_effect = mock_nodes + self.action.revert() - mock_irclient.node.set_power_state.assert_called_once_with( - COMPUTE_NODE, change_node_power_state.NodeState.POWERON.value) + mock_nodes[0].set_power_state.assert_called_once_with( + m_constants.PowerState.ON.value) diff --git a/watcher/tests/common/metal_helper/__init__.py b/watcher/tests/common/metal_helper/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/watcher/tests/common/metal_helper/test_base.py b/watcher/tests/common/metal_helper/test_base.py new file mode 100644 index 000000000..3547ec94f --- /dev/null +++ b/watcher/tests/common/metal_helper/test_base.py @@ -0,0 +1,96 @@ +# Copyright 2023 Cloudbase Solutions +# 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. + +from unittest import mock + +from watcher.common import exception +from watcher.common.metal_helper import base as m_helper_base +from watcher.common.metal_helper import constants as m_constants +from watcher.tests import base + + +# The base classes have abstract methods, we'll need to +# stub them. +class MockMetalNode(m_helper_base.BaseMetalNode): + def get_power_state(self): + raise NotImplementedError() + + def get_id(self): + raise NotImplementedError() + + def power_on(self): + raise NotImplementedError() + + def power_off(self): + raise NotImplementedError() + + +class MockMetalHelper(m_helper_base.BaseMetalHelper): + def list_compute_nodes(self): + pass + + def get_node(self, node_id): + pass + + +class TestBaseMetalNode(base.TestCase): + def setUp(self): + super().setUp() + + self._nova_node = mock.Mock() + self._node = MockMetalNode(self._nova_node) + + def test_get_hypervisor_node(self): + self.assertEqual( + self._nova_node, + self._node.get_hypervisor_node()) + + def test_get_hypervisor_node_missing(self): + node = MockMetalNode() + self.assertRaises( + exception.Invalid, + node.get_hypervisor_node) + + def test_get_hypervisor_hostname(self): + self.assertEqual( + self._nova_node.hypervisor_hostname, + self._node.get_hypervisor_hostname()) + + @mock.patch.object(MockMetalNode, 'power_on') + @mock.patch.object(MockMetalNode, 'power_off') + def test_set_power_state(self, + mock_power_off, mock_power_on): + self._node.set_power_state(m_constants.PowerState.ON) + mock_power_on.assert_called_once_with() + + self._node.set_power_state(m_constants.PowerState.OFF) + mock_power_off.assert_called_once_with() + + self.assertRaises( + exception.UnsupportedActionType, + self._node.set_power_state, + m_constants.PowerState.UNKNOWN) + + +class TestBaseMetalHelper(base.TestCase): + def setUp(self): + super().setUp() + + self._osc = mock.Mock() + self._helper = MockMetalHelper(self._osc) + + def test_nova_client_attr(self): + self.assertEqual(self._osc.nova.return_value, + self._helper.nova_client) diff --git a/watcher/tests/common/metal_helper/test_factory.py b/watcher/tests/common/metal_helper/test_factory.py new file mode 100644 index 000000000..0ba114ba1 --- /dev/null +++ b/watcher/tests/common/metal_helper/test_factory.py @@ -0,0 +1,38 @@ +# Copyright 2023 Cloudbase Solutions +# 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. + +from unittest import mock + +from watcher.common import clients +from watcher.common.metal_helper import factory +from watcher.common.metal_helper import ironic +from watcher.common.metal_helper import maas +from watcher.tests import base + + +class TestMetalHelperFactory(base.TestCase): + + @mock.patch.object(clients, 'OpenStackClients') + @mock.patch.object(maas, 'MaasHelper') + @mock.patch.object(ironic, 'IronicHelper') + def test_factory(self, mock_ironic, mock_maas, mock_osc): + self.assertEqual( + mock_ironic.return_value, + factory.get_helper()) + + self.config(url="fake_maas_url", group="maas_client") + self.assertEqual( + mock_maas.return_value, + factory.get_helper()) diff --git a/watcher/tests/common/metal_helper/test_ironic.py b/watcher/tests/common/metal_helper/test_ironic.py new file mode 100644 index 000000000..6f88e647f --- /dev/null +++ b/watcher/tests/common/metal_helper/test_ironic.py @@ -0,0 +1,128 @@ +# Copyright 2023 Cloudbase Solutions +# 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. + +from unittest import mock + +from watcher.common.metal_helper import constants as m_constants +from watcher.common.metal_helper import ironic +from watcher.tests import base + + +class TestIronicNode(base.TestCase): + def setUp(self): + super().setUp() + + self._wrapped_node = mock.Mock() + self._nova_node = mock.Mock() + self._ironic_client = mock.Mock() + + self._node = ironic.IronicNode( + self._wrapped_node, self._nova_node, self._ironic_client) + + def test_get_power_state(self): + states = ( + "power on", + "power off", + "rebooting", + "soft power off", + "soft reboot", + 'SomeOtherState') + type(self._wrapped_node).power_state = mock.PropertyMock( + side_effect=states) + + expected_states = ( + m_constants.PowerState.ON, + m_constants.PowerState.OFF, + m_constants.PowerState.ON, + m_constants.PowerState.OFF, + m_constants.PowerState.ON, + m_constants.PowerState.UNKNOWN) + + for expected_state in expected_states: + actual_state = self._node.get_power_state() + self.assertEqual(expected_state, actual_state) + + def test_get_id(self): + self.assertEqual( + self._wrapped_node.uuid, + self._node.get_id()) + + def test_power_on(self): + self._node.power_on() + self._ironic_client.node.set_power_state.assert_called_once_with( + self._wrapped_node.uuid, "on") + + def test_power_off(self): + self._node.power_off() + self._ironic_client.node.set_power_state.assert_called_once_with( + self._wrapped_node.uuid, "off") + + +class TestIronicHelper(base.TestCase): + def setUp(self): + super().setUp() + + self._mock_osc = mock.Mock() + self._mock_nova_client = self._mock_osc.nova.return_value + self._mock_ironic_client = self._mock_osc.ironic.return_value + self._helper = ironic.IronicHelper(osc=self._mock_osc) + + def test_list_compute_nodes(self): + mock_machines = [ + mock.Mock( + extra=dict(compute_node_id=mock.sentinel.compute_node_id)), + mock.Mock( + extra=dict(compute_node_id=mock.sentinel.compute_node_id2)), + mock.Mock( + extra=dict()) + ] + mock_hypervisor = mock.Mock() + + self._mock_ironic_client.node.list.return_value = mock_machines + self._mock_ironic_client.node.get.side_effect = mock_machines + self._mock_nova_client.hypervisors.get.side_effect = ( + mock_hypervisor, None) + + out_nodes = self._helper.list_compute_nodes() + self.assertEqual(1, len(out_nodes)) + + out_node = out_nodes[0] + self.assertIsInstance(out_node, ironic.IronicNode) + self.assertEqual(mock_hypervisor, out_node._nova_node) + self.assertEqual(mock_machines[0], out_node._ironic_node) + self.assertEqual(self._mock_ironic_client, out_node._ironic_client) + + def test_get_node(self): + mock_machine = mock.Mock( + extra=dict(compute_node_id=mock.sentinel.compute_node_id)) + self._mock_ironic_client.node.get.return_value = mock_machine + + out_node = self._helper.get_node(mock.sentinel.id) + + self.assertEqual(self._mock_nova_client.hypervisors.get.return_value, + out_node._nova_node) + self.assertEqual(self._mock_ironic_client, out_node._ironic_client) + self.assertEqual(mock_machine, out_node._ironic_node) + + def test_get_node_not_a_hypervisor(self): + mock_machine = mock.Mock(extra=dict(compute_node_id=None)) + self._mock_ironic_client.node.get.return_value = mock_machine + + out_node = self._helper.get_node(mock.sentinel.id) + + self._mock_nova_client.hypervisors.get.assert_not_called() + self.assertIsNone(out_node._nova_node) + self.assertEqual(self._mock_ironic_client, out_node._ironic_client) + self.assertEqual(mock_machine, out_node._ironic_node) diff --git a/watcher/tests/common/metal_helper/test_maas.py b/watcher/tests/common/metal_helper/test_maas.py new file mode 100644 index 000000000..4afa49a72 --- /dev/null +++ b/watcher/tests/common/metal_helper/test_maas.py @@ -0,0 +1,126 @@ +# Copyright 2023 Cloudbase Solutions +# 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. + +from unittest import mock + +try: + from maas.client import enum as maas_enum +except ImportError: + maas_enum = None + +from watcher.common.metal_helper import constants as m_constants +from watcher.common.metal_helper import maas +from watcher.tests import base + + +class TestMaasNode(base.TestCase): + def setUp(self): + super().setUp() + + self._wrapped_node = mock.Mock() + self._nova_node = mock.Mock() + self._maas_client = mock.Mock() + + self._node = maas.MaasNode( + self._wrapped_node, self._nova_node, self._maas_client) + + def test_get_power_state(self): + if not maas_enum: + self.skipTest("python-libmaas not intalled.") + + self._wrapped_node.query_power_state.side_effect = ( + maas_enum.PowerState.ON, + maas_enum.PowerState.OFF, + maas_enum.PowerState.ERROR, + maas_enum.PowerState.UNKNOWN, + 'SomeOtherState') + + expected_states = ( + m_constants.PowerState.ON, + m_constants.PowerState.OFF, + m_constants.PowerState.ERROR, + m_constants.PowerState.UNKNOWN, + m_constants.PowerState.UNKNOWN) + + for expected_state in expected_states: + actual_state = self._node.get_power_state() + self.assertEqual(expected_state, actual_state) + + def test_get_id(self): + self.assertEqual( + self._wrapped_node.system_id, + self._node.get_id()) + + def test_power_on(self): + self._node.power_on() + self._wrapped_node.power_on.assert_called_once_with() + + def test_power_off(self): + self._node.power_off() + self._wrapped_node.power_off.assert_called_once_with() + + +class TestMaasHelper(base.TestCase): + def setUp(self): + super().setUp() + + self._mock_osc = mock.Mock() + self._mock_nova_client = self._mock_osc.nova.return_value + self._mock_maas_client = self._mock_osc.maas.return_value + self._helper = maas.MaasHelper(osc=self._mock_osc) + + def test_list_compute_nodes(self): + compute_fqdn = "compute-0" + # some other MAAS node, not a Nova node + ctrl_fqdn = "ctrl-1" + + mock_machines = [ + mock.Mock(fqdn=compute_fqdn, + system_id=mock.sentinel.compute_node_id), + mock.Mock(fqdn=ctrl_fqdn, + system_id=mock.sentinel.ctrl_node_id), + ] + mock_hypervisors = [ + mock.Mock(hypervisor_hostname=compute_fqdn), + ] + + self._mock_maas_client.machines.list.return_value = mock_machines + self._mock_nova_client.hypervisors.list.return_value = mock_hypervisors + + out_nodes = self._helper.list_compute_nodes() + self.assertEqual(1, len(out_nodes)) + + out_node = out_nodes[0] + self.assertIsInstance(out_node, maas.MaasNode) + self.assertEqual(mock.sentinel.compute_node_id, out_node.get_id()) + self.assertEqual(compute_fqdn, out_node.get_hypervisor_hostname()) + + def test_get_node(self): + mock_machine = mock.Mock(fqdn='compute-0') + self._mock_maas_client.machines.get.return_value = mock_machine + + mock_compute_nodes = [ + mock.Mock(hypervisor_hostname="compute-011"), + mock.Mock(hypervisor_hostname="compute-0"), + mock.Mock(hypervisor_hostname="compute-01"), + ] + self._mock_nova_client.hypervisors.search.return_value = ( + mock_compute_nodes) + + out_node = self._helper.get_node(mock.sentinel.id) + + self.assertEqual(mock_compute_nodes[1], out_node._nova_node) + self.assertEqual(self._mock_maas_client, out_node._maas_client) + self.assertEqual(mock_machine, out_node._maas_node) diff --git a/watcher/tests/common/test_utils.py b/watcher/tests/common/test_utils.py new file mode 100644 index 000000000..155506bcd --- /dev/null +++ b/watcher/tests/common/test_utils.py @@ -0,0 +1,52 @@ +# Copyright 2023 Cloudbase Solutions +# 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. + +import asyncio +import time +from unittest import mock + +from watcher.common import utils +from watcher.tests import base + + +class TestCommonUtils(base.TestCase): + async def test_coro(self, sleep=0, raise_exc=None): + time.sleep(sleep) + if raise_exc: + raise raise_exc + return mock.sentinel.ret_val + + def test_async_compat(self): + ret_val = utils.async_compat_call(self.test_coro) + self.assertEqual(mock.sentinel.ret_val, ret_val) + + def test_async_compat_exc(self): + self.assertRaises( + IOError, + utils.async_compat_call, + self.test_coro, + raise_exc=IOError('fake error')) + + def test_async_compat_timeout(self): + # Timeout not reached. + ret_val = utils.async_compat_call(self.test_coro, timeout=10) + self.assertEqual(mock.sentinel.ret_val, ret_val) + + # Timeout reached. + self.assertRaises( + asyncio.TimeoutError, + utils.async_compat_call, + self.test_coro, + sleep=0.5, timeout=0.1) diff --git a/watcher/tests/decision_engine/fake_metal_helper.py b/watcher/tests/decision_engine/fake_metal_helper.py new file mode 100644 index 000000000..4c3dfc7cb --- /dev/null +++ b/watcher/tests/decision_engine/fake_metal_helper.py @@ -0,0 +1,47 @@ +# Copyright (c) 2023 Cloudbase Solutions +# +# 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 unittest import mock +import uuid + +from watcher.common.metal_helper import constants as m_constants + + +def get_mock_metal_node(node_id=None, + power_state=m_constants.PowerState.ON, + running_vms=0, + hostname=None, + compute_state='up'): + node_id = node_id or str(uuid.uuid4()) + # NOTE(lpetrut): the hostname is important for some of the tests, + # which expect it to match the fake cluster model. + hostname = hostname or "compute-" + str(uuid.uuid4()).split('-')[0] + + hypervisor_node_dict = { + 'hypervisor_hostname': hostname, + 'running_vms': running_vms, + 'service': { + 'host': hostname, + }, + 'state': compute_state, + } + hypervisor_node = mock.Mock(**hypervisor_node_dict) + hypervisor_node.to_dict.return_value = hypervisor_node_dict + + node = mock.Mock() + node.get_power_state.return_value = power_state + node.get_id.return_value = uuid + node.get_hypervisor_node.return_value = hypervisor_node + return node diff --git a/watcher/tests/decision_engine/strategy/strategies/test_saving_energy.py b/watcher/tests/decision_engine/strategy/strategies/test_saving_energy.py index c8082d154..aaba37431 100644 --- a/watcher/tests/decision_engine/strategy/strategies/test_saving_energy.py +++ b/watcher/tests/decision_engine/strategy/strategies/test_saving_energy.py @@ -18,8 +18,10 @@ from unittest import mock from watcher.common import clients +from watcher.common.metal_helper import constants as m_constants from watcher.common import utils from watcher.decision_engine.strategy import strategies +from watcher.tests.decision_engine import fake_metal_helper from watcher.tests.decision_engine.strategy.strategies.test_base \ import TestBaseStrategy @@ -29,26 +31,15 @@ class TestSavingEnergy(TestBaseStrategy): def setUp(self): super(TestSavingEnergy, self).setUp() - mock_node1_dict = { - 'uuid': '922d4762-0bc5-4b30-9cb9-48ab644dd861'} - mock_node2_dict = { - 'uuid': '922d4762-0bc5-4b30-9cb9-48ab644dd862'} - mock_node1 = mock.Mock(**mock_node1_dict) - mock_node2 = mock.Mock(**mock_node2_dict) - self.fake_nodes = [mock_node1, mock_node2] + self.fake_nodes = [fake_metal_helper.get_mock_metal_node(), + fake_metal_helper.get_mock_metal_node()] + self._metal_helper = mock.Mock() + self._metal_helper.list_compute_nodes.return_value = self.fake_nodes - p_ironic = mock.patch.object( - clients.OpenStackClients, 'ironic') - self.m_ironic = p_ironic.start() - self.addCleanup(p_ironic.stop) - - p_nova = mock.patch.object( - clients.OpenStackClients, 'nova') + p_nova = mock.patch.object(clients.OpenStackClients, 'nova') self.m_nova = p_nova.start() self.addCleanup(p_nova.stop) - self.m_ironic.node.list.return_value = self.fake_nodes - self.m_c_model.return_value = self.fake_c_cluster.generate_scenario_1() self.strategy = strategies.SavingEnergy( @@ -59,27 +50,20 @@ class TestSavingEnergy(TestBaseStrategy): 'min_free_hosts_num': 1}) self.strategy.free_used_percent = 10.0 self.strategy.min_free_hosts_num = 1 - self.strategy._ironic_client = self.m_ironic + self.strategy._metal_helper = self._metal_helper self.strategy._nova_client = self.m_nova def test_get_hosts_pool_with_vms_node_pool(self): - mock_node1_dict = { - 'extra': {'compute_node_id': 1}, - 'power_state': 'power on'} - mock_node2_dict = { - 'extra': {'compute_node_id': 2}, - 'power_state': 'power off'} - mock_node1 = mock.Mock(**mock_node1_dict) - mock_node2 = mock.Mock(**mock_node2_dict) - self.m_ironic.node.get.side_effect = [mock_node1, mock_node2] - - mock_hyper1 = mock.Mock() - mock_hyper2 = mock.Mock() - mock_hyper1.to_dict.return_value = { - 'running_vms': 2, 'service': {'host': 'hostname_0'}, 'state': 'up'} - mock_hyper2.to_dict.return_value = { - 'running_vms': 2, 'service': {'host': 'hostname_1'}, 'state': 'up'} - self.m_nova.hypervisors.get.side_effect = [mock_hyper1, mock_hyper2] + self._metal_helper.list_compute_nodes.return_value = [ + fake_metal_helper.get_mock_metal_node( + power_state=m_constants.PowerState.ON, + hostname='hostname_0', + running_vms=2), + fake_metal_helper.get_mock_metal_node( + power_state=m_constants.PowerState.OFF, + hostname='hostname_1', + running_vms=2), + ] self.strategy.get_hosts_pool() @@ -88,23 +72,16 @@ class TestSavingEnergy(TestBaseStrategy): self.assertEqual(len(self.strategy.free_poweroff_node_pool), 0) def test_get_hosts_pool_free_poweron_node_pool(self): - mock_node1_dict = { - 'extra': {'compute_node_id': 1}, - 'power_state': 'power on'} - mock_node2_dict = { - 'extra': {'compute_node_id': 2}, - 'power_state': 'power on'} - mock_node1 = mock.Mock(**mock_node1_dict) - mock_node2 = mock.Mock(**mock_node2_dict) - self.m_ironic.node.get.side_effect = [mock_node1, mock_node2] - - mock_hyper1 = mock.Mock() - mock_hyper2 = mock.Mock() - mock_hyper1.to_dict.return_value = { - 'running_vms': 0, 'service': {'host': 'hostname_0'}, 'state': 'up'} - mock_hyper2.to_dict.return_value = { - 'running_vms': 0, 'service': {'host': 'hostname_1'}, 'state': 'up'} - self.m_nova.hypervisors.get.side_effect = [mock_hyper1, mock_hyper2] + self._metal_helper.list_compute_nodes.return_value = [ + fake_metal_helper.get_mock_metal_node( + power_state=m_constants.PowerState.ON, + hostname='hostname_0', + running_vms=0), + fake_metal_helper.get_mock_metal_node( + power_state=m_constants.PowerState.ON, + hostname='hostname_1', + running_vms=0), + ] self.strategy.get_hosts_pool() @@ -113,23 +90,16 @@ class TestSavingEnergy(TestBaseStrategy): self.assertEqual(len(self.strategy.free_poweroff_node_pool), 0) def test_get_hosts_pool_free_poweroff_node_pool(self): - mock_node1_dict = { - 'extra': {'compute_node_id': 1}, - 'power_state': 'power off'} - mock_node2_dict = { - 'extra': {'compute_node_id': 2}, - 'power_state': 'power off'} - mock_node1 = mock.Mock(**mock_node1_dict) - mock_node2 = mock.Mock(**mock_node2_dict) - self.m_ironic.node.get.side_effect = [mock_node1, mock_node2] - - mock_hyper1 = mock.Mock() - mock_hyper2 = mock.Mock() - mock_hyper1.to_dict.return_value = { - 'running_vms': 0, 'service': {'host': 'hostname_0'}, 'state': 'up'} - mock_hyper2.to_dict.return_value = { - 'running_vms': 0, 'service': {'host': 'hostname_1'}, 'state': 'up'} - self.m_nova.hypervisors.get.side_effect = [mock_hyper1, mock_hyper2] + self._metal_helper.list_compute_nodes.return_value = [ + fake_metal_helper.get_mock_metal_node( + power_state=m_constants.PowerState.OFF, + hostname='hostname_0', + running_vms=0), + fake_metal_helper.get_mock_metal_node( + power_state=m_constants.PowerState.OFF, + hostname='hostname_1', + running_vms=0), + ] self.strategy.get_hosts_pool() @@ -138,26 +108,16 @@ class TestSavingEnergy(TestBaseStrategy): self.assertEqual(len(self.strategy.free_poweroff_node_pool), 2) def test_get_hosts_pool_with_node_out_model(self): - mock_node1_dict = { - 'extra': {'compute_node_id': 1}, - 'power_state': 'power off'} - mock_node2_dict = { - 'extra': {'compute_node_id': 2}, - 'power_state': 'power off'} - mock_node1 = mock.Mock(**mock_node1_dict) - mock_node2 = mock.Mock(**mock_node2_dict) - self.m_ironic.node.get.side_effect = [mock_node1, mock_node2] - - mock_hyper1 = mock.Mock() - mock_hyper2 = mock.Mock() - mock_hyper1.to_dict.return_value = { - 'running_vms': 0, 'service': {'host': 'hostname_0'}, - 'state': 'up'} - mock_hyper2.to_dict.return_value = { - 'running_vms': 0, 'service': {'host': 'hostname_10'}, - 'state': 'up'} - self.m_nova.hypervisors.get.side_effect = [mock_hyper1, mock_hyper2] - + self._metal_helper.list_compute_nodes.return_value = [ + fake_metal_helper.get_mock_metal_node( + power_state=m_constants.PowerState.OFF, + hostname='hostname_0', + running_vms=0), + fake_metal_helper.get_mock_metal_node( + power_state=m_constants.PowerState.OFF, + hostname='hostname_10', + running_vms=0), + ] self.strategy.get_hosts_pool() self.assertEqual(len(self.strategy.with_vms_node_pool), 0) @@ -166,9 +126,9 @@ class TestSavingEnergy(TestBaseStrategy): def test_save_energy_poweron(self): self.strategy.free_poweroff_node_pool = [ - mock.Mock(uuid='922d4762-0bc5-4b30-9cb9-48ab644dd861'), - mock.Mock(uuid='922d4762-0bc5-4b30-9cb9-48ab644dd862') - ] + fake_metal_helper.get_mock_metal_node(), + fake_metal_helper.get_mock_metal_node(), + ] self.strategy.save_energy() self.assertEqual(len(self.strategy.solution.actions), 1) action = self.strategy.solution.actions[0] @@ -185,23 +145,16 @@ class TestSavingEnergy(TestBaseStrategy): self.assertEqual(action.get('input_parameters').get('state'), 'off') def test_execute(self): - mock_node1_dict = { - 'extra': {'compute_node_id': 1}, - 'power_state': 'power on'} - mock_node2_dict = { - 'extra': {'compute_node_id': 2}, - 'power_state': 'power on'} - mock_node1 = mock.Mock(**mock_node1_dict) - mock_node2 = mock.Mock(**mock_node2_dict) - self.m_ironic.node.get.side_effect = [mock_node1, mock_node2] - - mock_hyper1 = mock.Mock() - mock_hyper2 = mock.Mock() - mock_hyper1.to_dict.return_value = { - 'running_vms': 0, 'service': {'host': 'hostname_0'}, 'state': 'up'} - mock_hyper2.to_dict.return_value = { - 'running_vms': 0, 'service': {'host': 'hostname_1'}, 'state': 'up'} - self.m_nova.hypervisors.get.side_effect = [mock_hyper1, mock_hyper2] + self._metal_helper.list_compute_nodes.return_value = [ + fake_metal_helper.get_mock_metal_node( + power_state=m_constants.PowerState.ON, + hostname='hostname_0', + running_vms=0), + fake_metal_helper.get_mock_metal_node( + power_state=m_constants.PowerState.ON, + hostname='hostname_1', + running_vms=0), + ] model = self.fake_c_cluster.generate_scenario_1() self.m_c_model.return_value = model