diff --git a/esi/lib/nodes.py b/esi/lib/nodes.py index b504435..7ed808c 100644 --- a/esi/lib/nodes.py +++ b/esi/lib/nodes.py @@ -13,6 +13,8 @@ import concurrent.futures +from openstack import exceptions + from esi.lib import networks @@ -138,3 +140,86 @@ def network_list(connection, filter_node=None, filter_network=None): }) return data + + +def network_attach(connection, node, filter_network=None, filter_port=None, filter_trunk=None, mac_address=None): + """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 network: The name or ID of a network + :param port: The name or ID of a network port + :param trunk: The name or ID of a network trunk + :param 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] + } + """ + + if (filter_network and filter_port) or (filter_network and filter_trunk) or (filter_port and filter_trunk): + raise exceptions.InvalidRequest('Specify only one of network, port or trunk') + if not filter_network and not filter_port and not filter_trunk: + raise exceptions.InvalidRequest('You must specify either network, port, or trunk') + + if filter_network: + network = connection.network.find_network(filter_network, ignore_missing=False) + network_port = None + elif filter_port: + network_port = connection.network.find_port(filter_port, ignore_missing=False) + elif filter_trunk: + trunk = connection.network.find_trunk(filter_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 filter_network: + port_name = 'esi-{0}-{1}'.format(node.name, 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=network.id, device_owner='baremetal:none') + elif filter_trunk: + network_port = connection.network.find_port(trunk.port_id, ignore_missing=False) + + data = {'id': network_port.id} + if mac_address: + data['port_uuid'] = baremetal_ports[0].id + connection.session.post('{0}/v1/nodes/{1}/vifs'.format(baremetal_endpoint, node.name), json=data, headers={'X-OpenStack-Ironic-API-Version': '1.69'}) + network_port = connection.network.find_port(network_port.id, ignore_missing=False) + + network, trunk_networks, trunk_ports, _ \ + = networks.get_networks_from_port(connection, + network_port, + networks_dict={network.id: network} + if 'network' in locals() else {}) + + return { + 'node': node, + 'ports': [network_port] + trunk_ports, + 'networks': [network] + trunk_networks + } diff --git a/esi/tests/unit/lib/test_nodes.py b/esi/tests/unit/lib/test_nodes.py index b6dfb11..bfb980a 100644 --- a/esi/tests/unit/lib/test_nodes.py +++ b/esi/tests/unit/lib/test_nodes.py @@ -520,3 +520,207 @@ 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.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({ + "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 + self.connection.baremetal.get_node.\ + return_value = self.node + + def mock_baremetal_ports(details=False, node=None, address=None): + if node is None or node == 'node1' or node == 'node_uuid_1': + if address == "aa:aa:aa:aa:aa:aa": + return [self.port1] + if address == "bb:bb:bb:bb:bb:bb": + return [self.port2] + return [self.port1, self.port2] + 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 + + @mock.patch('esi.lib.networks.get_networks_from_port') + def test_network_attach_network(self, mock_gnfp): + mock_gnfp.return_value = (self.network, [], [], None) + + actual = nodes.network_attach(self.connection, + 'node1', + filter_network='test_network') + + 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'}) + 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) + + actual = nodes.network_attach(self.connection, + 'node1', + filter_port='node1-port1') + + 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'}) + 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) + + actual = nodes.network_attach(self.connection, + 'node1', + filter_port='node1-port1', + mac_address='bb:bb:bb:bb:bb:bb') + + 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, node='node_uuid_1', 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'}) + 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) + + actual = nodes.network_attach(self.connection, + 'node1', + filter_trunk='test_trunk') + + 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'}) + self.connection.network.find_port.assert_any_call('neutron_port_uuid_3', ignore_missing=False) + self.assertEqual(expected, actual)