diff --git a/lower-constraints.txt b/lower-constraints.txt index 6e083e716..562bbc707 100644 --- a/lower-constraints.txt +++ b/lower-constraints.txt @@ -14,6 +14,7 @@ pbr==5.5.1 podman==1.6.0 python-glanceclient==3.2.2 python-heatclient==2.3.0 +python-ironicclient==4.6.1 python-neutronclient==7.2.1 python-novaclient==17.2.1 python-octaviaclient==2.2.0 diff --git a/requirements.txt b/requirements.txt index 4bfbefd68..a0589a440 100644 --- a/requirements.txt +++ b/requirements.txt @@ -13,6 +13,7 @@ paramiko>=2.7.2 # LGPLv2.1 pbr>=5.5.1 # Apache-2.0 python-glanceclient>=3.2.2 # Apache-2.0 python-heatclient>=2.3.0 # Apache-2.0 +python-ironicclient>=4.6.1 # Apache-2.0 python-neutronclient>=7.2.1 # Apache-2.0 python-novaclient>=17.2.1 # Apache-2.0 python-octaviaclient>=2.2.0 # Apache-2.0 diff --git a/tobiko/openstack/ironic/__init__.py b/tobiko/openstack/ironic/__init__.py new file mode 100644 index 000000000..6dabc5b4e --- /dev/null +++ b/tobiko/openstack/ironic/__init__.py @@ -0,0 +1,23 @@ +# Copyright 2021 Red Hat +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +from __future__ import absolute_import + +from tobiko.openstack.ironic import _client +from tobiko.openstack.ironic import _node + +get_ironic_client = _client.get_ironic_client + +power_off_node = _node.power_off_node +power_on_node = _node.power_on_node +IronicNodeType = _node.IronicNodeType diff --git a/tobiko/openstack/ironic/_client.py b/tobiko/openstack/ironic/_client.py new file mode 100644 index 000000000..af687d8db --- /dev/null +++ b/tobiko/openstack/ironic/_client.py @@ -0,0 +1,74 @@ +# Copyright 2021 Red Hat +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +from __future__ import absolute_import + +import typing + +import ironicclient +import ironicclient.v1.client +from oslo_log import log + +import tobiko +from tobiko.openstack import _client + + +LOG = log.getLogger(__name__) + +CLIENT_CLASSES = (ironicclient.v1.client.Client,) +IronicClient = typing.Union[ironicclient.v1.client.Client] + + +class IronicClientFixture(_client.OpenstackClientFixture): + + def init_client(self, session) -> IronicClient: + return ironicclient.client.get_client(1, session=session) + + +class IronicClientManager(_client.OpenstackClientManager): + + def create_client(self, session) -> IronicClientFixture: + return IronicClientFixture(session=session) + + +CLIENTS = IronicClientManager() + +IronicClientType = typing.Union[IronicClient, + IronicClientFixture, + typing.Type[IronicClientFixture], + None] + + +def ironic_client(obj: IronicClientType) -> IronicClient: + if obj is None: + return get_ironic_client() + + if isinstance(obj, CLIENT_CLASSES): + return obj + + fixture = tobiko.setup_fixture(obj) + if isinstance(fixture, IronicClientFixture): + assert fixture.client is not None + return fixture.client + + message = f"Object '{obj}' is not an IronicClientFixture instance" + raise TypeError(message) + + +def get_ironic_client(session=None, shared=True, init_client=None, + manager=None) -> IronicClient: + manager = manager or CLIENTS + client = manager.get_client(session=session, shared=shared, + init_client=init_client) + tobiko.setup_fixture(client) + return client.client diff --git a/tobiko/openstack/ironic/_node.py b/tobiko/openstack/ironic/_node.py new file mode 100644 index 000000000..090c08c91 --- /dev/null +++ b/tobiko/openstack/ironic/_node.py @@ -0,0 +1,151 @@ +# Copyright 2021 Red Hat +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +from __future__ import absolute_import + +import typing + +import ironicclient.v1.client +from oslo_log import log + +import tobiko +from tobiko.openstack.ironic import _client + + +LOG = log.getLogger(__name__) + +IronicNode = typing.Union[ironicclient.v1.node.Node] +IronicNodeType = typing.Union[str, IronicNode] + + +def get_node_id(node: typing.Optional[IronicNodeType] = None, + node_id: typing.Optional[str] = None) -> str: + if node_id is None: + if isinstance(node, str): + node_id = node + else: + assert node is not None + node_id = node.uuid + return node_id + + +def get_node(node: typing.Optional[IronicNodeType] = None, + node_id: typing.Optional[str] = None, + client: _client.IronicClientType = None, + **params) -> IronicNode: + node_id = get_node_id(node=node, node_id=node_id) + return _client.ironic_client(client).node.get(node_id, **params) + + +class WaitForNodePowerStateError(tobiko.TobikoException): + message = ("Node {node_id} not changing power state from " + "{node_power_state} to {power_state}") + + +class WaitForNodePowerStateTimeout(WaitForNodePowerStateError): + message = ("Node {node_id} didn't change its state from " + "{node_power_state} to {power_state} state after " + "{timeout} seconds") + + +IRONIC_NODE_TRANSIENT_POWER_STATES: typing.Dict[str, typing.List[str]] = { + 'power on': ['power off'], + 'power off': ['power on'], +} + + +def wait_for_node_power_state( + node: IronicNodeType, + power_state: str, + client: _client.IronicClientType = None, + timeout: tobiko.Seconds = None, + sleep_time: tobiko.Seconds = None, + transient_status: typing.Optional[typing.List[str]] = None) -> \ + IronicNode: + if transient_status is None: + transient_status = IRONIC_NODE_TRANSIENT_POWER_STATES.get( + power_state) or [] + node_id = get_node_id(node) + for attempt in tobiko.retry(timeout=timeout, + interval=sleep_time, + default_timeout=300., + default_interval=5.): + _node = get_node(node_id=node_id, client=client) + if _node.power_state == power_state: + break + + if _node.power_state not in transient_status: + raise WaitForNodePowerStateError( + node_id=node_id, + node_power_state=_node.power_state, + power_state=power_state) + try: + attempt.check_time_left() + except tobiko.RetryTimeLimitError as ex: + raise WaitForNodePowerStateTimeout( + node_id=node_id, + node_power_state=_node.power_state, + power_state=power_state, + timeout=timeout) from ex + + LOG.debug(f"Waiting for baremetal node {node_id} power state to get " + f"from {_node.power_state} to {power_state}...") + else: + raise RuntimeError("Retry look break before timing out") + + return _node + + +def power_off_node(node: IronicNodeType, + soft=False, + client: _client.IronicClientType = None, + timeout: tobiko.Seconds = None, + sleep_time: tobiko.Seconds = None) \ + -> IronicNode: + client = _client.ironic_client(client) + node = get_node(node=node, client=client) + if node.power_state == 'power off': + return node + + LOG.info(f"Power off baremetal node '{node.uuid}' " + f"(power state = '{node.power_state}').") + client.node.set_power_state(node.uuid, + state='off', + soft=soft, + timeout=timeout) + return wait_for_node_power_state(node=node.uuid, + power_state='power off', + client=client, + timeout=timeout, + sleep_time=sleep_time) + + +def power_on_node(node: IronicNodeType, + client: _client.IronicClientType = None, + timeout: tobiko.Seconds = None, + sleep_time: tobiko.Seconds = None) -> \ + IronicNode: + client = _client.ironic_client(client) + node = get_node(node=node, client=client) + if node.power_state == 'power on': + return node + + LOG.info(f"Power on baremetal node '{node.uuid}' " + f"(power_state='{node.power_state}').") + client.node.set_power_state(node_id=node.uuid, state='on') + + return wait_for_node_power_state(node=node.uuid, + power_state='power on', + client=client, + timeout=timeout, + sleep_time=sleep_time) diff --git a/tobiko/openstack/nova/__init__.py b/tobiko/openstack/nova/__init__.py index fc0ffc721..6c56b7d62 100644 --- a/tobiko/openstack/nova/__init__.py +++ b/tobiko/openstack/nova/__init__.py @@ -45,6 +45,7 @@ migrate_server = _client.migrate_server confirm_resize = _client.confirm_resize reboot_server = _client.reboot_server NovaServer = _client.NovaServer +ServerType = _client.ServerType WaitForCloudInitTimeoutError = _cloud_init.WaitForCloudInitTimeoutError cloud_config = _cloud_init.cloud_config