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:
parent
9492c2190e
commit
c95ce4ec17
1
tox.ini
1
tox.ini
@ -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
|
||||||
|
@ -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.")
|
||||||
|
@ -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:
|
||||||
|
0
watcher/common/metal_helper/__init__.py
Normal file
0
watcher/common/metal_helper/__init__.py
Normal file
81
watcher/common/metal_helper/base.py
Normal file
81
watcher/common/metal_helper/base.py
Normal 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
|
23
watcher/common/metal_helper/constants.py
Normal file
23
watcher/common/metal_helper/constants.py
Normal 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"
|
33
watcher/common/metal_helper/factory.py
Normal file
33
watcher/common/metal_helper/factory.py
Normal 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)
|
94
watcher/common/metal_helper/ironic.py
Normal file
94
watcher/common/metal_helper/ironic.py
Normal 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)
|
125
watcher/common/metal_helper/maas.py
Normal file
125
watcher/common/metal_helper/maas.py
Normal 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)
|
@ -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)
|
||||||
|
@ -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)
|
||||||
|
38
watcher/conf/maas_client.py
Normal file
38
watcher/conf/maas_client.py
Normal 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)]
|
@ -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):
|
||||||
|
@ -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)
|
||||||
|
|
||||||
|
@ -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()
|
||||||
|
@ -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)
|
||||||
|
0
watcher/tests/common/metal_helper/__init__.py
Normal file
0
watcher/tests/common/metal_helper/__init__.py
Normal file
96
watcher/tests/common/metal_helper/test_base.py
Normal file
96
watcher/tests/common/metal_helper/test_base.py
Normal 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)
|
38
watcher/tests/common/metal_helper/test_factory.py
Normal file
38
watcher/tests/common/metal_helper/test_factory.py
Normal 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())
|
128
watcher/tests/common/metal_helper/test_ironic.py
Normal file
128
watcher/tests/common/metal_helper/test_ironic.py
Normal 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)
|
126
watcher/tests/common/metal_helper/test_maas.py
Normal file
126
watcher/tests/common/metal_helper/test_maas.py
Normal 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)
|
52
watcher/tests/common/test_utils.py
Normal file
52
watcher/tests/common/test_utils.py
Normal 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)
|
47
watcher/tests/decision_engine/fake_metal_helper.py
Normal file
47
watcher/tests/decision_engine/fake_metal_helper.py
Normal 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
|
@ -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
|
||||||
|
Loading…
Reference in New Issue
Block a user