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