From 2d1c11b784a10299cc760c3f8ae1e5ba63444030 Mon Sep 17 00:00:00 2001 From: Austin Jamias Date: Fri, 19 Jul 2024 14:41:01 -0400 Subject: [PATCH] Migrate Node Network Attach Functionality Into esisdk Originally, this functionality existed in python-esiclient. It is being moved into esisdk so both python-esiclient and esi-ui can use it. esi-leap's node resource has added more attributes: target_provision_state, power_state, and target_power_state. This change is to add those attributes to esisdk's node resource. --- esi/lease/v1/node.py | 3 + esi/lib/nodes.py | 109 +++++++++++ esi/tests/unit/lib/test_nodes.py | 309 +++++++++++++++++++++++++++++++ 3 files changed, 421 insertions(+) diff --git a/esi/lease/v1/node.py b/esi/lease/v1/node.py index f58d939..5020fc5 100644 --- a/esi/lease/v1/node.py +++ b/esi/lease/v1/node.py @@ -41,6 +41,9 @@ class Node(resource.Resource): owner = resource.Body("owner") lessee = resource.Body("lessee") provision_state = resource.Body("provision_state") + target_provision_state = resource.Body("target_provision_state") + power_state = resource.Body("power_state") + target_power_state = resource.Body("target_power_state") maintenance = resource.Body("maintenance") offer_uuid = resource.Body("offer_uuid") lease_uuid = resource.Body("lease_uuid") diff --git a/esi/lib/nodes.py b/esi/lib/nodes.py index b504435..9ac12f1 100644 --- a/esi/lib/nodes.py +++ b/esi/lib/nodes.py @@ -13,9 +13,14 @@ import concurrent.futures +from openstack import exceptions + from esi.lib import networks +OPENSTACK_IRONIC_API_VERSION = '1.69' + + def node_and_port_list(connection, filter_node=None): """Get lists baremetal nodes and ports @@ -138,3 +143,107 @@ def network_list(connection, filter_node=None, filter_network=None): }) return data + + +def network_attach(connection, node, attach_info): + """Attaches a node's bare metal port to a network port + + :param connection: An OpenStack connection + :type connection: :class:`~openstack.connection.Connection` + :param node: The name or ID of a node + :param attach_info: A dictionary. Possible entrys are: + * 'network': + * 'port': (The network port to attach) + * 'trunk': + * 'mac_address': (The MAC address of the bare metal port to attach) + + :returns: a dictionary with the resulting node and network information + { + 'node': openstack.baremetal.v1.node.Node, + 'ports': [openstack.network.v2.port.Port] + 'networks': [openstack.network.v2.network.Network] + } + """ + + network = attach_info.get('network') + port = attach_info.get('port') + trunk = attach_info.get('trunk') + mac_address = attach_info.get('mac_address') + + if (network and port) or (network and trunk) or (port and trunk): + raise exceptions.InvalidRequest('Specify only one of network, port, or trunk') + if not network and not port and not trunk: + raise exceptions.InvalidRequest('You must specify either network, port, or trunk') + + if network: + parent_network = connection.network.find_network(network, ignore_missing=False) + network_port = None + elif port: + network_port = connection.network.find_port(port, ignore_missing=False) + elif trunk: + trunk_network = connection.network.find_trunk(trunk, ignore_missing=False) + network_port = None + + with concurrent.futures.ThreadPoolExecutor() as executor: + f1 = executor.submit(connection.baremetal.get_node, node) + f2 = executor.submit(connection.session.get_endpoint, + service_type='baremetal', + service_name='ironic', + interface='public') + node = f1.result() + baremetal_endpoint = f2.result() + + if mac_address: + baremetal_ports = list(connection.baremetal.ports(details=True, address=mac_address)) + if len(baremetal_ports) == 0: + raise exceptions.ResourceFailure('MAC address {0} does not exist on node {1}'.format(mac_address, node.name)) + else: + baremetal_ports = connection.baremetal.ports(details=True, node=node.id) + has_free_port = False + for bp in baremetal_ports: + if 'tenant_vif_port_id' not in bp.internal_info: + has_free_port = True + break + + if not has_free_port: + raise exceptions.ResourceFailure('Node {0} has no free ports'.format(node.name)) + + if network: + port_name = 'esi-{0}-{1}'.format(node.name, parent_network.name) + network_ports = list(connection.network.ports(name=port_name, status='DOWN')) + if len(network_ports) > 0: + network_port = network_ports[0] + else: + network_port = connection.network.create_port(name=port_name, + network_id=parent_network.id, + device_owner='baremetal:none') + elif trunk: + network_port = connection.network.find_port(trunk_network.port_id, ignore_missing=False) + + data = {'id': network_port.id} + if mac_address: + data['port_uuid'] = baremetal_ports[0].id + + # TODO(ajamias) There should be a function in openstacksdk that specifies + # a bare metal port's MAC address to attach a network port to + connection.session.post( + '{0}/v1/nodes/{1}/vifs'.format(baremetal_endpoint, node.id), + headers={'X-OpenStack-Ironic-API-Version': OPENSTACK_IRONIC_API_VERSION}, + json=data) + network_port = connection.network.find_port(network_port.id, ignore_missing=False) + + networks_dict = {} + if network: + networks_dict[parent_network.id] = parent_network + elif trunk: + networks_dict[trunk_network.id] = trunk_network + parent_network, trunk_networks, trunk_ports, _ \ + = networks.get_networks_from_port(connection, + network_port, + networks_dict=networks_dict) + + return { + 'node': node, + 'ports': [network_port] + trunk_ports, + 'networks': [parent_network] + trunk_networks + } diff --git a/esi/tests/unit/lib/test_nodes.py b/esi/tests/unit/lib/test_nodes.py index b6dfb11..833dd4b 100644 --- a/esi/tests/unit/lib/test_nodes.py +++ b/esi/tests/unit/lib/test_nodes.py @@ -13,6 +13,8 @@ import mock from unittest import TestCase +from openstack import exceptions + from esi.lib import nodes from esi.tests.unit import utils @@ -520,3 +522,310 @@ def test_network_list_filter_node_network(self): self.connection.network.ports.assert_called_once_with(network_id='network_uuid_2') self.connection.network.port_forwardings.assert_called_once_with(floating_ip=self.floating_ip_pfw) self.connection.network.get_port.assert_not_called() + + +class TestNetworkAttach(TestCase): + + def setUp(self): + super(TestNetworkAttach, self).setUp() + + self.port1 = utils.create_mock_object({ + "id": "port_uuid_1", + "node_uuid": "node_uuid_1", + "address": "aa:aa:aa:aa:aa:aa", + "internal_info": {'tenant_vif_port_id': 'neutron_port_uuid_1'} + }) + self.port2 = utils.create_mock_object({ + "id": "port_uuid_2", + "node_uuid": "node_uuid_1", + "address": "bb:bb:bb:bb:bb:bb", + "internal_info": {} + }) + self.port3 = utils.create_mock_object({ + "uuid": "port_uuid_3", + "node_uuid": "node_uuid_1", + "address": "cc:cc:cc:cc:cc:cc", + "internal_info": {} + }) + self.node = utils.create_mock_object({ + "id": "node_uuid_1", + "name": "node1", + "provision_state": "active" + }) + self.node2 = utils.create_mock_object({ + "id": "node_uuid_2", + "name": "node2", + "provision_state": "active" + }) + self.node_available = utils.create_mock_object({ + "uuid": "node_uuid_1", + "name": "node1", + "provision_state": "available" + }) + self.node_manageable = utils.create_mock_object({ + "uuid": "node_uuid_1", + "name": "node1", + "provision_state": "manageable", + "instance_info": {}, + "driver_info": {'deploy_ramdisk': 'fake-image'}, + }) + self.node_manageable_instance_info = utils.create_mock_object({ + "uuid": "node_uuid_1", + "name": "node1", + "provision_state": "manageable", + "instance_info": {'image_source': 'fake-image', + 'capabilities': {}}, + "driver_info": {'deploy_ramdisk': 'fake-image'}, + }) + self.network = utils.create_mock_object({ + "id": "network_uuid", + "name": "test_network" + }) + self.network2 = utils.create_mock_object({ + "id": "network_uuid_2", + "name": "test_network_2" + }) + self.neutron_port1 = utils.create_mock_object({ + "id": "neutron_port_uuid_2", + "network_id": "network_uuid", + "name": "node1-port1", + "mac_address": "bb:bb:bb:bb:bb:bb", + "fixed_ips": [{"ip_address": "2.2.2.2"}], + "trunk_details": None + }) + self.neutron_port2 = utils.create_mock_object({ + "id": "neutron_port_uuid_3", + "network_id": "network_uuid_2", + "name": "node1-port2", + "mac_address": "cc:cc:cc:cc:cc:cc", + "fixed_ips": [{"ip_address": "3.3.3.3"}], + "trunk_details": { + 'sub_ports': [ + {'port_id': self.neutron_port1.id}, + ] + } + }) + self.trunk = utils.create_mock_object({ + "id": "trunk_id", + "port_id": 'neutron_port_uuid_3', + "name": "test_trunk" + }) + + self.connection = mock.Mock() + + self.connection.network.find_network.\ + return_value = self.network + self.connection.session.get_endpoint.\ + return_value = 'endpoint' + self.connection.network.create_port.\ + return_value = self.neutron_port1 + self.connection.network.ports.\ + return_value = [] + self.connection.network.find_trunk.\ + return_value = self.trunk + + def mock_baremetal_ports(details=False, node=None, address=None): + if node is None or node == 'node1' or node == 'node_uuid_1': + if address is None: + return [self.port1, self.port2] + if address == "aa:aa:aa:aa:aa:aa": + return [self.port1] + if address == "bb:bb:bb:bb:bb:bb": + return [self.port2] + return [] + return [] + self.connection.baremetal.ports.side_effect = mock_baremetal_ports + + def mock_find_port(port, ignore_missing=True): + if port == 'neutron_port_uuid_2' or port == 'node1-port1': + return self.neutron_port1 + elif port == 'neutron_port_uuid_3' or port == 'node1-port2': + return self.neutron_port2 + return None + self.connection.network.find_port.side_effect = mock_find_port + + def mock_get_node(node): + if node == 'node1' or node == 'node_uuid_1': + return self.node + if node == 'node2' or node == 'node_uuid_2': + return self.node2 + return None + self.connection.baremetal.get_node.side_effect = mock_get_node + + @mock.patch('esi.lib.networks.get_networks_from_port') + def test_network_attach_network(self, mock_gnfp): + mock_gnfp.return_value = (self.network, [], [], None) + + attach_info = { + 'network': 'test_network' + } + + actual = nodes.network_attach(self.connection, + 'node1', + attach_info) + + expected = { + 'node': self.node, + 'ports': [self.neutron_port1], + 'networks': [self.network] + } + + self.connection.network.find_network.assert_called_once_with('test_network', ignore_missing=False) + self.connection.baremetal.get_node.assert_called_once_with('node1') + self.connection.baremetal.ports.assert_called_once_with(details=True, node='node_uuid_1') + self.connection.network.ports.assert_called_once_with(name='esi-node1-test_network', status='DOWN') + self.connection.network.create_port.assert_called_once_with(name='esi-node1-test_network', network_id='network_uuid', device_owner='baremetal:none') + self.connection.session.post.assert_called_once_with('endpoint/v1/nodes/node_uuid_1/vifs', json={'id': 'neutron_port_uuid_2'}, headers={'X-OpenStack-Ironic-API-Version': '1.69'}) + self.connection.network.find_port.assert_called_once_with('neutron_port_uuid_2', ignore_missing=False) + self.assertEqual(expected, actual) + + @mock.patch('esi.lib.networks.get_networks_from_port') + def test_network_attach_port(self, mock_gnfp): + mock_gnfp.return_value = (self.network, [], [], None) + + attach_info = { + 'port': 'node1-port1' + } + + actual = nodes.network_attach(self.connection, + 'node1', + attach_info) + + expected = { + 'node': self.node, + 'ports': [self.neutron_port1], + 'networks': [self.network] + } + + self.connection.network.find_port.assert_any_call('node1-port1', ignore_missing=False) + self.connection.baremetal.get_node.assert_called_once_with('node1') + self.connection.baremetal.ports.assert_called_once_with(details=True, node='node_uuid_1') + self.connection.session.post.assert_called_once_with('endpoint/v1/nodes/node_uuid_1/vifs', json={'id': 'neutron_port_uuid_2'}, headers={'X-OpenStack-Ironic-API-Version': '1.69'}) + self.connection.network.find_port.assert_any_call('neutron_port_uuid_2', ignore_missing=False) + self.assertEqual(expected, actual) + + @mock.patch('esi.lib.networks.get_networks_from_port') + def test_network_attach_port_and_mac_address(self, mock_gnfp): + mock_gnfp.return_value = (self.network, [], [], None) + + attach_info = { + 'mac_address': 'bb:bb:bb:bb:bb:bb', + 'port': 'node1-port1' + } + + actual = nodes.network_attach(self.connection, + 'node1', + attach_info) + + expected = { + 'node': self.node, + 'ports': [self.neutron_port1], + 'networks': [self.network] + } + + self.connection.network.find_port.assert_any_call('node1-port1', ignore_missing=False) + self.connection.baremetal.get_node.assert_called_once_with('node1') + self.connection.session.get_endpoint.assert_called_once_with(service_type='baremetal', service_name='ironic', interface='public') + self.connection.baremetal.ports.assert_called_once_with(details=True, address='bb:bb:bb:bb:bb:bb') + self.connection.session.post.assert_called_once_with('endpoint/v1/nodes/node_uuid_1/vifs', json={'id': 'neutron_port_uuid_2', 'port_uuid': 'port_uuid_2'}, headers={'X-OpenStack-Ironic-API-Version': '1.69'}) + self.connection.network.find_port.assert_any_call('neutron_port_uuid_2', ignore_missing=False) + self.assertEqual(expected, actual) + + @mock.patch('esi.lib.networks.get_networks_from_port') + def test_network_attach_trunk(self, mock_gnfp): + mock_gnfp.return_value = (self.network2, [self.network], [self.neutron_port1], None) + + attach_info = { + 'trunk': 'test_trunk' + } + + actual = nodes.network_attach(self.connection, + 'node1', + attach_info) + + expected = { + 'node': self.node, + 'ports': [self.neutron_port2, self.neutron_port1], + 'networks': [self.network2, self.network] + } + + self.connection.network.find_trunk.assert_called_once_with('test_trunk', ignore_missing=False) + self.connection.baremetal.get_node.assert_called_once_with('node1') + self.connection.baremetal.ports.assert_called_once_with(details=True, node='node_uuid_1') + self.connection.network.find_port.assert_any_call('neutron_port_uuid_3', ignore_missing=False) + self.connection.session.post.assert_called_once_with('endpoint/v1/nodes/node_uuid_1/vifs', json={'id': 'neutron_port_uuid_3'}, headers={'X-OpenStack-Ironic-API-Version': '1.69'}) + self.connection.network.find_port.assert_any_call('neutron_port_uuid_3', ignore_missing=False) + self.assertEqual(expected, actual) + + def test_network_attach_invalid_request(self): + attach_info = { + 'network': 'test_network', + 'port': 'node1-port1' + } + self.assertRaisesRegex( + exceptions.InvalidRequest, + 'Specify only one of network, port, or trunk', + nodes.network_attach, + self.connection, + 'node1', + attach_info) + + attach_info = { + 'network': 'test_network', + 'trunk': 'test_trunk' + } + self.assertRaisesRegex( + exceptions.InvalidRequest, + 'Specify only one of network, port, or trunk', + nodes.network_attach, + self.connection, + 'node1', + attach_info) + + attach_info = { + 'port': 'node1-port1', + 'trunk': 'test_trunk' + } + self.assertRaisesRegex( + exceptions.InvalidRequest, + 'Specify only one of network, port, or trunk', + nodes.network_attach, + self.connection, + 'node1', + attach_info) + + attach_info = {} + self.assertRaisesRegex( + exceptions.InvalidRequest, + 'You must specify either network, port, or trunk', + nodes.network_attach, + self.connection, + 'node1', + attach_info) + + def test_network_attach_mac_address_dne(self): + attach_info = { + 'network': 'test_network', + 'mac_address': 'does_not_exist' + } + + self.assertRaisesRegex( + exceptions.ResourceFailure, + 'MAC address does_not_exist does not exist on node node1', + nodes.network_attach, + self.connection, + 'node1', + attach_info) + + def test_network_attach_no_free_ports(self): + attach_info = { + 'network': 'test_network', + } + + self.assertRaisesRegex( + exceptions.ResourceFailure, + 'Node node2 has no free ports', + nodes.network_attach, + self.connection, + 'node2', + attach_info)