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
This commit is contained in:
Lucian Petrut 2023-10-05 10:50:56 +03:00
parent 9492c2190e
commit c95ce4ec17
24 changed files with 1154 additions and 228 deletions

View File

@ -14,6 +14,7 @@ setenv =
deps = deps =
-r{toxinidir}/test-requirements.txt -r{toxinidir}/test-requirements.txt
-r{toxinidir}/requirements.txt -r{toxinidir}/requirements.txt
python-libmaas>=0.6.8
commands = commands =
rm -f .testrepository/times.dbm rm -f .testrepository/times.dbm
find . -type f -name "*.py[c|o]" -delete find . -type f -name "*.py[c|o]" -delete

View File

@ -17,17 +17,17 @@
# limitations under the License. # limitations under the License.
# #
import enum
import time import time
from oslo_log import log
from watcher._i18n import _ from watcher._i18n import _
from watcher.applier.actions import base from watcher.applier.actions import base
from watcher.common import exception 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
LOG = log.getLogger(__name__)
class NodeState(enum.Enum):
POWERON = 'on'
POWEROFF = 'off'
class ChangeNodePowerState(base.BaseAction): class ChangeNodePowerState(base.BaseAction):
@ -43,8 +43,8 @@ class ChangeNodePowerState(base.BaseAction):
'state': str, 'state': str,
}) })
The `resource_id` references a ironic node id (list of available The `resource_id` references a baremetal node id (list of available
ironic node is returned by this command: ``ironic node-list``). ironic nodes is returned by this command: ``ironic node-list``).
The `state` value should either be `on` or `off`. The `state` value should either be `on` or `off`.
""" """
@ -65,8 +65,8 @@ class ChangeNodePowerState(base.BaseAction):
}, },
'state': { 'state': {
'type': 'string', 'type': 'string',
'enum': [NodeState.POWERON.value, 'enum': [metal_constants.PowerState.ON.value,
NodeState.POWEROFF.value] metal_constants.PowerState.OFF.value]
} }
}, },
'required': ['resource_id', 'state'], 'required': ['resource_id', 'state'],
@ -86,10 +86,10 @@ class ChangeNodePowerState(base.BaseAction):
return self._node_manage_power(target_state) return self._node_manage_power(target_state)
def revert(self): def revert(self):
if self.state == NodeState.POWERON.value: if self.state == metal_constants.PowerState.ON.value:
target_state = NodeState.POWEROFF.value target_state = metal_constants.PowerState.OFF.value
elif self.state == NodeState.POWEROFF.value: elif self.state == metal_constants.PowerState.OFF.value:
target_state = NodeState.POWERON.value target_state = metal_constants.PowerState.ON.value
return self._node_manage_power(target_state) return self._node_manage_power(target_state)
def _node_manage_power(self, state, retry=60): def _node_manage_power(self, state, retry=60):
@ -97,30 +97,32 @@ class ChangeNodePowerState(base.BaseAction):
raise exception.IllegalArgumentException( raise exception.IllegalArgumentException(
message=_("The target state is not defined")) message=_("The target state is not defined"))
ironic_client = self.osc.ironic() metal_helper = metal_helper_factory.get_helper(self.osc)
nova_client = self.osc.nova() node = metal_helper.get_node(self.node_uuid)
current_state = ironic_client.node.get(self.node_uuid).power_state current_state = node.get_power_state()
# power state: 'power on' or 'power off', if current node state
# is the same as state, just return True if state == current_state.value:
if state in current_state:
return True return True
if state == NodeState.POWEROFF.value: if state == metal_constants.PowerState.OFF.value:
node_info = ironic_client.node.get(self.node_uuid).to_dict() compute_node = node.get_hypervisor_node().to_dict()
compute_node_id = node_info['extra']['compute_node_id']
compute_node = nova_client.hypervisors.get(compute_node_id)
compute_node = compute_node.to_dict()
if (compute_node['running_vms'] == 0): if (compute_node['running_vms'] == 0):
ironic_client.node.set_power_state( node.set_power_state(state)
self.node_uuid, 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: 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) node = metal_helper.get_node(self.node_uuid)
while ironic_node.power_state == current_state and retry: while node.get_power_state() == current_state and retry:
time.sleep(10) time.sleep(10)
retry -= 1 retry -= 1
ironic_node = ironic_client.node.get(self.node_uuid) node = metal_helper.get_node(self.node_uuid)
if retry > 0: if retry > 0:
return True return True
else: else:
@ -134,4 +136,4 @@ class ChangeNodePowerState(base.BaseAction):
def get_description(self): def get_description(self):
"""Description of the action""" """Description of the action"""
return ("Compute node power on/off through ironic.") return ("Compute node power on/off through Ironic or MaaS.")

View File

@ -25,6 +25,7 @@ from novaclient import api_versions as nova_api_versions
from novaclient import client as nvclient from novaclient import client as nvclient
from watcher.common import exception from watcher.common import exception
from watcher.common import utils
try: try:
from ceilometerclient import client as ceclient from ceilometerclient import client as ceclient
@ -32,6 +33,12 @@ try:
except ImportError: except ImportError:
HAS_CEILCLIENT = False HAS_CEILCLIENT = False
try:
from maas import client as maas_client
except ImportError:
maas_client = None
CONF = cfg.CONF CONF = cfg.CONF
_CLIENTS_AUTH_GROUP = 'watcher_clients_auth' _CLIENTS_AUTH_GROUP = 'watcher_clients_auth'
@ -74,6 +81,7 @@ class OpenStackClients(object):
self._monasca = None self._monasca = None
self._neutron = None self._neutron = None
self._ironic = None self._ironic = None
self._maas = None
self._placement = None self._placement = None
def _get_keystone_session(self): def _get_keystone_session(self):
@ -265,6 +273,23 @@ class OpenStackClients(object):
session=self.session) session=self.session)
return self._ironic 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 @exception.wrap_keystone_exception
def placement(self): def placement(self):
if self._placement: if self._placement:

View File

View File

@ -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

View File

@ -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"

View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -16,12 +16,16 @@
"""Utilities and helper functions.""" """Utilities and helper functions."""
import asyncio
import datetime import datetime
import inspect
import random import random
import re import re
import string import string
from croniter import croniter from croniter import croniter
import eventlet
from eventlet import tpool
from jsonschema import validators from jsonschema import validators
from oslo_config import cfg from oslo_config import cfg
@ -162,3 +166,37 @@ Draft4Validator = validators.Draft4Validator
def random_string(n): def random_string(n):
return ''.join([random.choice( return ''.join([random.choice(
string.ascii_letters + string.digits) for i in range(n)]) 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)

View File

@ -35,6 +35,7 @@ from watcher.conf import grafana_client
from watcher.conf import grafana_translators from watcher.conf import grafana_translators
from watcher.conf import ironic_client from watcher.conf import ironic_client
from watcher.conf import keystone_client from watcher.conf import keystone_client
from watcher.conf import maas_client
from watcher.conf import monasca_client from watcher.conf import monasca_client
from watcher.conf import neutron_client from watcher.conf import neutron_client
from watcher.conf import nova_client from watcher.conf import nova_client
@ -54,6 +55,7 @@ db.register_opts(CONF)
planner.register_opts(CONF) planner.register_opts(CONF)
applier.register_opts(CONF) applier.register_opts(CONF)
decision_engine.register_opts(CONF) decision_engine.register_opts(CONF)
maas_client.register_opts(CONF)
monasca_client.register_opts(CONF) monasca_client.register_opts(CONF)
nova_client.register_opts(CONF) nova_client.register_opts(CONF)
glance_client.register_opts(CONF) glance_client.register_opts(CONF)

View File

@ -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)]

View File

@ -81,6 +81,7 @@ class BareMetalModelBuilder(base.BaseModelBuilder):
def __init__(self, osc): def __init__(self, osc):
self.osc = osc self.osc = osc
self.model = model_root.BaremetalModelRoot() self.model = model_root.BaremetalModelRoot()
# TODO(lpetrut): add MAAS support
self.ironic_helper = ironic_helper.IronicHelper(osc=self.osc) self.ironic_helper = ironic_helper.IronicHelper(osc=self.osc)
def add_ironic_node(self, node): def add_ironic_node(self, node):

View File

@ -157,7 +157,7 @@ class ModelRoot(nx.DiGraph, base.Model):
if node_list: if node_list:
return node_list[0] return node_list[0]
else: else:
raise exception.ComputeResourceNotFound raise exception.ComputeNodeNotFound(name=name)
except exception.ComputeResourceNotFound: except exception.ComputeResourceNotFound:
raise exception.ComputeNodeNotFound(name=name) raise exception.ComputeNodeNotFound(name=name)

View File

@ -23,6 +23,8 @@ from oslo_log import log
from watcher._i18n import _ from watcher._i18n import _
from watcher.common import exception 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 from watcher.decision_engine.strategy.strategies import base
LOG = log.getLogger(__name__) LOG = log.getLogger(__name__)
@ -81,7 +83,7 @@ class SavingEnergy(base.SavingEnergyBaseStrategy):
def __init__(self, config, osc=None): def __init__(self, config, osc=None):
super(SavingEnergy, self).__init__(config, osc) super(SavingEnergy, self).__init__(config, osc)
self._ironic_client = None self._metal_helper = None
self._nova_client = None self._nova_client = None
self.with_vms_node_pool = [] self.with_vms_node_pool = []
@ -91,10 +93,10 @@ class SavingEnergy(base.SavingEnergyBaseStrategy):
self.min_free_hosts_num = 1 self.min_free_hosts_num = 1
@property @property
def ironic_client(self): def metal_helper(self):
if not self._ironic_client: if not self._metal_helper:
self._ironic_client = self.osc.ironic() self._metal_helper = metal_helper_factory.get_helper(self.osc)
return self._ironic_client return self._metal_helper
@property @property
def nova_client(self): def nova_client(self):
@ -149,10 +151,10 @@ class SavingEnergy(base.SavingEnergyBaseStrategy):
:return: None :return: None
""" """
params = {'state': state, params = {'state': state,
'resource_name': node.hostname} 'resource_name': node.get_hypervisor_hostname()}
self.solution.add_action( self.solution.add_action(
action_type='change_node_power_state', action_type='change_node_power_state',
resource_id=node.uuid, resource_id=node.get_id(),
input_parameters=params) input_parameters=params)
def get_hosts_pool(self): 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: for node in node_list:
node_info = self.ironic_client.node.get(node.uuid) hypervisor_node = node.get_hypervisor_node().to_dict()
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()
compute_service = hypervisor_node.get('service', None) compute_service = hypervisor_node.get('service', None)
host_name = compute_service.get('host') host_name = compute_service.get('host')
LOG.debug("Found hypervisor: %s", hypervisor_node)
try: try:
self.compute_model.get_node_by_name(host_name) self.compute_model.get_node_by_name(host_name)
except exception.ComputeNodeNotFound: except exception.ComputeNodeNotFound:
LOG.info("The compute model does not contain the host: %s",
host_name)
continue continue
if not (hypervisor_node.get('state') == 'up'): if (node.hv_up_when_powered_off and
"""filter nodes that are not in 'up' state""" 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 continue
else: else:
if (hypervisor_node['running_vms'] == 0): 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) 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) self.free_poweroff_node_pool.append(node)
else:
LOG.info("Ignoring node %s, unknown state: %s",
node, power_state)
else: else:
self.with_vms_node_pool.append(node) self.with_vms_node_pool.append(node)
@ -202,17 +204,21 @@ class SavingEnergy(base.SavingEnergyBaseStrategy):
self.min_free_hosts_num))) self.min_free_hosts_num)))
len_poweron = len(self.free_poweron_node_pool) len_poweron = len(self.free_poweron_node_pool)
len_poweroff = len(self.free_poweroff_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: if len_poweron > need_poweron:
for node in random.sample(self.free_poweron_node_pool, for node in random.sample(self.free_poweron_node_pool,
(len_poweron - need_poweron)): (len_poweron - need_poweron)):
self.add_action_poweronoff_node(node, 'off') self.add_action_poweronoff_node(node,
LOG.info("power off %s", node.uuid) metal_constants.PowerState.OFF)
LOG.info("power off %s", node.get_id())
elif len_poweron < need_poweron: elif len_poweron < need_poweron:
diff = need_poweron - len_poweron diff = need_poweron - len_poweron
for node in random.sample(self.free_poweroff_node_pool, for node in random.sample(self.free_poweroff_node_pool,
min(len_poweroff, diff)): min(len_poweroff, diff)):
self.add_action_poweronoff_node(node, 'on') self.add_action_poweronoff_node(node,
LOG.info("power on %s", node.uuid) metal_constants.PowerState.ON)
LOG.info("power on %s", node.get_id())
def pre_execute(self): def pre_execute(self):
self._pre_execute() self._pre_execute()

View File

@ -19,134 +19,151 @@ import jsonschema
from watcher.applier.actions import base as baction from watcher.applier.actions import base as baction
from watcher.applier.actions import change_node_power_state 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 import base
from watcher.tests.decision_engine import fake_metal_helper
COMPUTE_NODE = "compute-1" COMPUTE_NODE = "compute-1"
@mock.patch.object(clients.OpenStackClients, 'nova')
@mock.patch.object(clients.OpenStackClients, 'ironic')
class TestChangeNodePowerState(base.TestCase): class TestChangeNodePowerState(base.TestCase):
def setUp(self): def setUp(self):
super(TestChangeNodePowerState, self).setUp() 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 = { self.input_parameters = {
baction.BaseAction.RESOURCE_ID: COMPUTE_NODE, 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( self.action = change_node_power_state.ChangeNodePowerState(
mock.Mock()) mock.Mock())
self.action.input_parameters = self.input_parameters 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 = { self.action.input_parameters = {
baction.BaseAction.RESOURCE_ID: COMPUTE_NODE, baction.BaseAction.RESOURCE_ID: COMPUTE_NODE,
self.action.STATE: self.action.STATE:
change_node_power_state.NodeState.POWEROFF.value} m_constants.PowerState.OFF.value}
self.assertTrue(self.action.validate_parameters()) self.assertTrue(self.action.validate_parameters())
def test_parameters_up(self, mock_ironic, mock_nova): def test_parameters_up(self):
self.action.input_parameters = { self.action.input_parameters = {
baction.BaseAction.RESOURCE_ID: COMPUTE_NODE, baction.BaseAction.RESOURCE_ID: COMPUTE_NODE,
self.action.STATE: self.action.STATE:
change_node_power_state.NodeState.POWERON.value} m_constants.PowerState.ON.value}
self.assertTrue(self.action.validate_parameters()) 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 = { self.action.input_parameters = {
baction.BaseAction.RESOURCE_ID: COMPUTE_NODE, baction.BaseAction.RESOURCE_ID: COMPUTE_NODE,
self.action.STATE: 'error'} self.action.STATE: 'error'}
self.assertRaises(jsonschema.ValidationError, self.assertRaises(jsonschema.ValidationError,
self.action.validate_parameters) 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.input_parameters = {
self.action.STATE: self.action.STATE:
change_node_power_state.NodeState.POWERON.value, m_constants.PowerState.ON.value,
} }
self.assertRaises(jsonschema.ValidationError, self.assertRaises(jsonschema.ValidationError,
self.action.validate_parameters) 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.action.input_parameters = {"extra": "failed"}
self.assertRaises(jsonschema.ValidationError, self.assertRaises(jsonschema.ValidationError,
self.action.validate_parameters) 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: try:
self.action.pre_condition() self.action.pre_condition()
except Exception as exc: except Exception as exc:
self.fail(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: try:
self.action.post_condition() self.action.post_condition()
except Exception as exc: except Exception as exc:
self.fail(exc) self.fail(exc)
def test_execute_node_service_state_with_poweron_target( def test_execute_node_service_state_with_poweron_target(self):
self, mock_ironic, mock_nova):
mock_irclient = mock_ironic.return_value
self.action.input_parameters["state"] = ( self.action.input_parameters["state"] = (
change_node_power_state.NodeState.POWERON.value) m_constants.PowerState.ON.value)
mock_irclient.node.get.side_effect = [ mock_nodes = [
mock.MagicMock(power_state='power off'), fake_metal_helper.get_mock_metal_node(
mock.MagicMock(power_state='power on')] 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() result = self.action.execute()
self.assertTrue(result) self.assertTrue(result)
mock_irclient.node.set_power_state.assert_called_once_with( mock_nodes[0].set_power_state.assert_called_once_with(
COMPUTE_NODE, change_node_power_state.NodeState.POWERON.value) m_constants.PowerState.ON.value)
def test_execute_change_node_state_with_poweroff_target( def test_execute_change_node_state_with_poweroff_target(self):
self, mock_ironic, mock_nova):
mock_irclient = mock_ironic.return_value
mock_nvclient = mock_nova.return_value
mock_get = mock.MagicMock()
mock_get.to_dict.return_value = {'running_vms': 0}
mock_nvclient.hypervisors.get.return_value = mock_get
self.action.input_parameters["state"] = ( self.action.input_parameters["state"] = (
change_node_power_state.NodeState.POWEROFF.value) m_constants.PowerState.OFF.value)
mock_irclient.node.get.side_effect = [
mock.MagicMock(power_state='power on'), mock_nodes = [
mock.MagicMock(power_state='power on'), fake_metal_helper.get_mock_metal_node(
mock.MagicMock(power_state='power off')] 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() result = self.action.execute()
self.assertTrue(result) self.assertTrue(result)
mock_irclient.node.set_power_state.assert_called_once_with( mock_nodes[0].set_power_state.assert_called_once_with(
COMPUTE_NODE, change_node_power_state.NodeState.POWEROFF.value) m_constants.PowerState.OFF.value)
def test_revert_change_node_state_with_poweron_target( def test_revert_change_node_state_with_poweron_target(self):
self, mock_ironic, mock_nova):
mock_irclient = mock_ironic.return_value
mock_nvclient = mock_nova.return_value
mock_get = mock.MagicMock()
mock_get.to_dict.return_value = {'running_vms': 0}
mock_nvclient.hypervisors.get.return_value = mock_get
self.action.input_parameters["state"] = ( self.action.input_parameters["state"] = (
change_node_power_state.NodeState.POWERON.value) m_constants.PowerState.ON.value)
mock_irclient.node.get.side_effect = [
mock.MagicMock(power_state='power on'), mock_nodes = [
mock.MagicMock(power_state='power on'), fake_metal_helper.get_mock_metal_node(
mock.MagicMock(power_state='power off')] 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() self.action.revert()
mock_irclient.node.set_power_state.assert_called_once_with( mock_nodes[0].set_power_state.assert_called_once_with(
COMPUTE_NODE, change_node_power_state.NodeState.POWEROFF.value) m_constants.PowerState.OFF.value)
def test_revert_change_node_state_with_poweroff_target( def test_revert_change_node_state_with_poweroff_target(self):
self, mock_ironic, mock_nova):
mock_irclient = mock_ironic.return_value
self.action.input_parameters["state"] = ( self.action.input_parameters["state"] = (
change_node_power_state.NodeState.POWEROFF.value) m_constants.PowerState.OFF.value)
mock_irclient.node.get.side_effect = [ mock_nodes = [
mock.MagicMock(power_state='power off'), fake_metal_helper.get_mock_metal_node(
mock.MagicMock(power_state='power on')] 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() self.action.revert()
mock_irclient.node.set_power_state.assert_called_once_with( mock_nodes[0].set_power_state.assert_called_once_with(
COMPUTE_NODE, change_node_power_state.NodeState.POWERON.value) m_constants.PowerState.ON.value)

View File

@ -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)

View File

@ -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())

View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -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

View File

@ -18,8 +18,10 @@
from unittest import mock from unittest import mock
from watcher.common import clients from watcher.common import clients
from watcher.common.metal_helper import constants as m_constants
from watcher.common import utils from watcher.common import utils
from watcher.decision_engine.strategy import strategies 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 \ from watcher.tests.decision_engine.strategy.strategies.test_base \
import TestBaseStrategy import TestBaseStrategy
@ -29,26 +31,15 @@ class TestSavingEnergy(TestBaseStrategy):
def setUp(self): def setUp(self):
super(TestSavingEnergy, self).setUp() super(TestSavingEnergy, self).setUp()
mock_node1_dict = { self.fake_nodes = [fake_metal_helper.get_mock_metal_node(),
'uuid': '922d4762-0bc5-4b30-9cb9-48ab644dd861'} fake_metal_helper.get_mock_metal_node()]
mock_node2_dict = { self._metal_helper = mock.Mock()
'uuid': '922d4762-0bc5-4b30-9cb9-48ab644dd862'} self._metal_helper.list_compute_nodes.return_value = self.fake_nodes
mock_node1 = mock.Mock(**mock_node1_dict)
mock_node2 = mock.Mock(**mock_node2_dict)
self.fake_nodes = [mock_node1, mock_node2]
p_ironic = mock.patch.object( p_nova = mock.patch.object(clients.OpenStackClients, 'nova')
clients.OpenStackClients, 'ironic')
self.m_ironic = p_ironic.start()
self.addCleanup(p_ironic.stop)
p_nova = mock.patch.object(
clients.OpenStackClients, 'nova')
self.m_nova = p_nova.start() self.m_nova = p_nova.start()
self.addCleanup(p_nova.stop) 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.m_c_model.return_value = self.fake_c_cluster.generate_scenario_1()
self.strategy = strategies.SavingEnergy( self.strategy = strategies.SavingEnergy(
@ -59,27 +50,20 @@ class TestSavingEnergy(TestBaseStrategy):
'min_free_hosts_num': 1}) 'min_free_hosts_num': 1})
self.strategy.free_used_percent = 10.0 self.strategy.free_used_percent = 10.0
self.strategy.min_free_hosts_num = 1 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 self.strategy._nova_client = self.m_nova
def test_get_hosts_pool_with_vms_node_pool(self): def test_get_hosts_pool_with_vms_node_pool(self):
mock_node1_dict = { self._metal_helper.list_compute_nodes.return_value = [
'extra': {'compute_node_id': 1}, fake_metal_helper.get_mock_metal_node(
'power_state': 'power on'} power_state=m_constants.PowerState.ON,
mock_node2_dict = { hostname='hostname_0',
'extra': {'compute_node_id': 2}, running_vms=2),
'power_state': 'power off'} fake_metal_helper.get_mock_metal_node(
mock_node1 = mock.Mock(**mock_node1_dict) power_state=m_constants.PowerState.OFF,
mock_node2 = mock.Mock(**mock_node2_dict) hostname='hostname_1',
self.m_ironic.node.get.side_effect = [mock_node1, mock_node2] running_vms=2),
]
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.strategy.get_hosts_pool() self.strategy.get_hosts_pool()
@ -88,23 +72,16 @@ class TestSavingEnergy(TestBaseStrategy):
self.assertEqual(len(self.strategy.free_poweroff_node_pool), 0) self.assertEqual(len(self.strategy.free_poweroff_node_pool), 0)
def test_get_hosts_pool_free_poweron_node_pool(self): def test_get_hosts_pool_free_poweron_node_pool(self):
mock_node1_dict = { self._metal_helper.list_compute_nodes.return_value = [
'extra': {'compute_node_id': 1}, fake_metal_helper.get_mock_metal_node(
'power_state': 'power on'} power_state=m_constants.PowerState.ON,
mock_node2_dict = { hostname='hostname_0',
'extra': {'compute_node_id': 2}, running_vms=0),
'power_state': 'power on'} fake_metal_helper.get_mock_metal_node(
mock_node1 = mock.Mock(**mock_node1_dict) power_state=m_constants.PowerState.ON,
mock_node2 = mock.Mock(**mock_node2_dict) hostname='hostname_1',
self.m_ironic.node.get.side_effect = [mock_node1, mock_node2] running_vms=0),
]
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.strategy.get_hosts_pool() self.strategy.get_hosts_pool()
@ -113,23 +90,16 @@ class TestSavingEnergy(TestBaseStrategy):
self.assertEqual(len(self.strategy.free_poweroff_node_pool), 0) self.assertEqual(len(self.strategy.free_poweroff_node_pool), 0)
def test_get_hosts_pool_free_poweroff_node_pool(self): def test_get_hosts_pool_free_poweroff_node_pool(self):
mock_node1_dict = { self._metal_helper.list_compute_nodes.return_value = [
'extra': {'compute_node_id': 1}, fake_metal_helper.get_mock_metal_node(
'power_state': 'power off'} power_state=m_constants.PowerState.OFF,
mock_node2_dict = { hostname='hostname_0',
'extra': {'compute_node_id': 2}, running_vms=0),
'power_state': 'power off'} fake_metal_helper.get_mock_metal_node(
mock_node1 = mock.Mock(**mock_node1_dict) power_state=m_constants.PowerState.OFF,
mock_node2 = mock.Mock(**mock_node2_dict) hostname='hostname_1',
self.m_ironic.node.get.side_effect = [mock_node1, mock_node2] running_vms=0),
]
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.strategy.get_hosts_pool() self.strategy.get_hosts_pool()
@ -138,26 +108,16 @@ class TestSavingEnergy(TestBaseStrategy):
self.assertEqual(len(self.strategy.free_poweroff_node_pool), 2) self.assertEqual(len(self.strategy.free_poweroff_node_pool), 2)
def test_get_hosts_pool_with_node_out_model(self): def test_get_hosts_pool_with_node_out_model(self):
mock_node1_dict = { self._metal_helper.list_compute_nodes.return_value = [
'extra': {'compute_node_id': 1}, fake_metal_helper.get_mock_metal_node(
'power_state': 'power off'} power_state=m_constants.PowerState.OFF,
mock_node2_dict = { hostname='hostname_0',
'extra': {'compute_node_id': 2}, running_vms=0),
'power_state': 'power off'} fake_metal_helper.get_mock_metal_node(
mock_node1 = mock.Mock(**mock_node1_dict) power_state=m_constants.PowerState.OFF,
mock_node2 = mock.Mock(**mock_node2_dict) hostname='hostname_10',
self.m_ironic.node.get.side_effect = [mock_node1, mock_node2] running_vms=0),
]
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.strategy.get_hosts_pool() self.strategy.get_hosts_pool()
self.assertEqual(len(self.strategy.with_vms_node_pool), 0) self.assertEqual(len(self.strategy.with_vms_node_pool), 0)
@ -166,9 +126,9 @@ class TestSavingEnergy(TestBaseStrategy):
def test_save_energy_poweron(self): def test_save_energy_poweron(self):
self.strategy.free_poweroff_node_pool = [ self.strategy.free_poweroff_node_pool = [
mock.Mock(uuid='922d4762-0bc5-4b30-9cb9-48ab644dd861'), fake_metal_helper.get_mock_metal_node(),
mock.Mock(uuid='922d4762-0bc5-4b30-9cb9-48ab644dd862') fake_metal_helper.get_mock_metal_node(),
] ]
self.strategy.save_energy() self.strategy.save_energy()
self.assertEqual(len(self.strategy.solution.actions), 1) self.assertEqual(len(self.strategy.solution.actions), 1)
action = self.strategy.solution.actions[0] action = self.strategy.solution.actions[0]
@ -185,23 +145,16 @@ class TestSavingEnergy(TestBaseStrategy):
self.assertEqual(action.get('input_parameters').get('state'), 'off') self.assertEqual(action.get('input_parameters').get('state'), 'off')
def test_execute(self): def test_execute(self):
mock_node1_dict = { self._metal_helper.list_compute_nodes.return_value = [
'extra': {'compute_node_id': 1}, fake_metal_helper.get_mock_metal_node(
'power_state': 'power on'} power_state=m_constants.PowerState.ON,
mock_node2_dict = { hostname='hostname_0',
'extra': {'compute_node_id': 2}, running_vms=0),
'power_state': 'power on'} fake_metal_helper.get_mock_metal_node(
mock_node1 = mock.Mock(**mock_node1_dict) power_state=m_constants.PowerState.ON,
mock_node2 = mock.Mock(**mock_node2_dict) hostname='hostname_1',
self.m_ironic.node.get.side_effect = [mock_node1, mock_node2] running_vms=0),
]
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]
model = self.fake_c_cluster.generate_scenario_1() model = self.fake_c_cluster.generate_scenario_1()
self.m_c_model.return_value = model self.m_c_model.return_value = model