From d87624069f82fa766eb6f1575cdd33899822e6db Mon Sep 17 00:00:00 2001 From: Dmitry Tantsur Date: Mon, 13 Aug 2018 11:50:57 +0200 Subject: [PATCH] baremetal: add support for VIF attach/detach API Change-Id: Ifb311531e9750502a60afac0961e4919c4f8f1e5 --- doc/source/user/proxies/baremetal.rst | 8 ++ openstack/baremetal/v1/_common.py | 3 + openstack/baremetal/v1/_proxy.py | 55 +++++++++ openstack/baremetal/v1/node.py | 105 +++++++++++++++++- openstack/cloud/openstackcloud.py | 34 ++++++ .../baremetal/test_baremetal_node.py | 31 ++++++ .../tests/unit/baremetal/v1/test_node.py | 61 ++++++++++ openstack/tests/unit/base.py | 9 +- .../tests/unit/cloud/test_baremetal_node.py | 82 ++++++++++++++ .../notes/baremetal-vif-122457118c722a9b.yaml | 4 + 10 files changed, 386 insertions(+), 6 deletions(-) create mode 100644 releasenotes/notes/baremetal-vif-122457118c722a9b.yaml diff --git a/doc/source/user/proxies/baremetal.rst b/doc/source/user/proxies/baremetal.rst index 578724074..b3bacb035 100644 --- a/doc/source/user/proxies/baremetal.rst +++ b/doc/source/user/proxies/baremetal.rst @@ -65,6 +65,14 @@ Chassis Operations .. automethod:: openstack.baremetal.v1._proxy.Proxy.find_chassis .. automethod:: openstack.baremetal.v1._proxy.Proxy.chassis +VIF Operations +^^^^^^^^^^^^^^ +.. autoclass:: openstack.baremetal.v1._proxy.Proxy + + .. automethod:: openstack.baremetal.v1._proxy.Proxy.attach_vif_to_node + .. automethod:: openstack.baremetal.v1._proxy.Proxy.detach_vif_from_node + .. automethod:: openstack.baremetal.v1._proxy.Proxy.list_node_vifs + Deprecated Methods ^^^^^^^^^^^^^^^^^^ diff --git a/openstack/baremetal/v1/_common.py b/openstack/baremetal/v1/_common.py index ffa1ee668..4be2bd47e 100644 --- a/openstack/baremetal/v1/_common.py +++ b/openstack/baremetal/v1/_common.py @@ -52,3 +52,6 @@ STATE_VERSIONS = { 'manageable': '1.4', } """API versions when certain states were introduced.""" + +VIF_VERSION = '1.28' +"""API version in which the VIF operations were introduced.""" diff --git a/openstack/baremetal/v1/_proxy.py b/openstack/baremetal/v1/_proxy.py index a838faa62..abf02dc4d 100644 --- a/openstack/baremetal/v1/_proxy.py +++ b/openstack/baremetal/v1/_proxy.py @@ -702,3 +702,58 @@ class Proxy(proxy.Proxy): """ return self._delete(_portgroup.PortGroup, port_group, ignore_missing=ignore_missing) + + def attach_vif_to_node(self, node, vif_id): + """Attach a VIF to the node. + + The exact form of the VIF ID depends on the network interface used by + the node. In the most common case it is a Network service port + (NOT a Bare Metal port) ID. A VIF can only be attached to one node + at a time. + + :param node: The value can be either the name or ID of a node or + a :class:`~openstack.baremetal.v1.node.Node` instance. + :param string vif_id: Backend-specific VIF ID. + :return: ``None`` + :raises: :exc:`~openstack.exceptions.NotSupported` if the server + does not support the VIF API. + """ + res = self._get_resource(_node.Node, node) + res.attach_vif(self, vif_id) + + def detach_vif_from_node(self, node, vif_id, ignore_missing=True): + """Detach a VIF from the node. + + The exact form of the VIF ID depends on the network interface used by + the node. In the most common case it is a Network service port + (NOT a Bare Metal port) ID. + + :param node: The value can be either the name or ID of a node or + a :class:`~openstack.baremetal.v1.node.Node` instance. + :param string vif_id: Backend-specific VIF ID. + :param bool ignore_missing: When set to ``False`` + :class:`~openstack.exceptions.ResourceNotFound` will be + raised when the VIF does not exist. Otherwise, ``False`` + is returned. + :return: ``True`` if the VIF was detached, otherwise ``False``. + :raises: :exc:`~openstack.exceptions.NotSupported` if the server + does not support the VIF API. + """ + res = self._get_resource(_node.Node, node) + return res.detach_vif(self, vif_id, ignore_missing=ignore_missing) + + def list_node_vifs(self, node): + """List IDs of VIFs attached to the node. + + The exact form of the VIF ID depends on the network interface used by + the node. In the most common case it is a Network service port + (NOT a Bare Metal port) ID. + + :param node: The value can be either the name or ID of a node or + a :class:`~openstack.baremetal.v1.node.Node` instance. + :return: List of VIF IDs as strings. + :raises: :exc:`~openstack.exceptions.NotSupported` if the server + does not support the VIF API. + """ + res = self._get_resource(_node.Node, node) + return res.list_vifs(self) diff --git a/openstack/baremetal/v1/node.py b/openstack/baremetal/v1/node.py index c2f82cb81..1736b7290 100644 --- a/openstack/baremetal/v1/node.py +++ b/openstack/baremetal/v1/node.py @@ -41,8 +41,8 @@ class Node(resource.Resource): is_maintenance='maintenance', ) - # Full port groups support introduced in 1.24 - _max_microversion = '1.24' + # VIF attach/detach support introduced in 1.28. + _max_microversion = '1.28' # Properties #: The UUID of the chassis associated wit this node. Can be empty or None. @@ -341,6 +341,107 @@ class Node(resource.Resource): "the last error is %(error)s" % {'node': self.id, 'error': self.last_error}) + def attach_vif(self, session, vif_id): + """Attach a VIF to the node. + + The exact form of the VIF ID depends on the network interface used by + the node. In the most common case it is a Network service port + (NOT a Bare Metal port) ID. A VIF can only be attached to one node + at a time. + + :param session: The session to use for making this request. + :type session: :class:`~keystoneauth1.adapter.Adapter` + :param string vif_id: Backend-specific VIF ID. + :return: ``None`` + :raises: :exc:`~openstack.exceptions.NotSupported` if the server + does not support the VIF API. + """ + session = self._get_session(session) + version = self._assert_microversion_for( + session, 'commit', _common.VIF_VERSION, + error_message=("Cannot use VIF attachment API")) + + request = self._prepare_request(requires_id=True) + request.url = utils.urljoin(request.url, 'vifs') + body = {'id': vif_id} + response = session.post( + request.url, json=body, + headers=request.headers, microversion=version, + # NOTE(dtantsur): do not retry CONFLICT, it's a valid status code + # in this API when the VIF is already attached to another node. + retriable_status_codes=[503]) + + msg = ("Failed to attach VIF {vif} to bare metal node {node}" + .format(node=self.id, vif=vif_id)) + exceptions.raise_from_response(response, error_message=msg) + + def detach_vif(self, session, vif_id, ignore_missing=True): + """Detach a VIF from the node. + + The exact form of the VIF ID depends on the network interface used by + the node. In the most common case it is a Network service port + (NOT a Bare Metal port) ID. + + :param session: The session to use for making this request. + :type session: :class:`~keystoneauth1.adapter.Adapter` + :param string vif_id: Backend-specific VIF ID. + :param bool ignore_missing: When set to ``False`` + :class:`~openstack.exceptions.ResourceNotFound` will be + raised when the VIF does not exist. Otherwise, ``False`` + is returned. + :return: ``True`` if the VIF was detached, otherwise ``False``. + :raises: :exc:`~openstack.exceptions.NotSupported` if the server + does not support the VIF API. + """ + session = self._get_session(session) + version = self._assert_microversion_for( + session, 'commit', _common.VIF_VERSION, + error_message=("Cannot use VIF attachment API")) + + request = self._prepare_request(requires_id=True) + request.url = utils.urljoin(request.url, 'vifs', vif_id) + response = session.delete( + request.url, headers=request.headers, microversion=version, + retriable_status_codes=_common.RETRIABLE_STATUS_CODES) + + if ignore_missing and response.status_code == 400: + _logger.debug('VIF %(vif)s was already removed from node %(node)s', + {'vif': vif_id, 'node': self.id}) + return False + + msg = ("Failed to detach VIF {vif} from bare metal node {node}" + .format(node=self.id, vif=vif_id)) + exceptions.raise_from_response(response, error_message=msg) + return True + + def list_vifs(self, session): + """List IDs of VIFs attached to the node. + + The exact form of the VIF ID depends on the network interface used by + the node. In the most common case it is a Network service port + (NOT a Bare Metal port) ID. + + :param session: The session to use for making this request. + :type session: :class:`~keystoneauth1.adapter.Adapter` + :return: List of VIF IDs as strings. + :raises: :exc:`~openstack.exceptions.NotSupported` if the server + does not support the VIF API. + """ + session = self._get_session(session) + version = self._assert_microversion_for( + session, 'fetch', _common.VIF_VERSION, + error_message=("Cannot use VIF attachment API")) + + request = self._prepare_request(requires_id=True) + request.url = utils.urljoin(request.url, 'vifs') + response = session.get( + request.url, headers=request.headers, microversion=version) + + msg = ("Failed to list VIFs attached to bare metal node {node}" + .format(node=self.id)) + exceptions.raise_from_response(response, error_message=msg) + return [vif['id'] for vif in response.json()['vifs']] + class NodeDetail(Node): diff --git a/openstack/cloud/openstackcloud.py b/openstack/cloud/openstackcloud.py index 8ba114599..8de706eba 100755 --- a/openstack/cloud/openstackcloud.py +++ b/openstack/cloud/openstackcloud.py @@ -9821,6 +9821,40 @@ class OpenStackCloud(_normalize.Normalizer): changes=change_list ) + def attach_port_to_machine(self, name_or_id, port_name_or_id): + """Attach a virtual port to the bare metal machine. + + :param string name_or_id: A machine name or UUID. + :param string port_name_or_id: A port name or UUID. + Note that this is a Network service port, not a bare metal NIC. + :return: Nothing. + """ + machine = self.get_machine(name_or_id) + port = self.get_port(port_name_or_id) + self.baremetal.attach_vif_to_node(machine, port['id']) + + def detach_port_from_machine(self, name_or_id, port_name_or_id): + """Detach a virtual port from the bare metal machine. + + :param string name_or_id: A machine name or UUID. + :param string port_name_or_id: A port name or UUID. + Note that this is a Network service port, not a bare metal NIC. + :return: Nothing. + """ + machine = self.get_machine(name_or_id) + port = self.get_port(port_name_or_id) + self.baremetal.detach_vif_from_node(machine, port['id']) + + def list_ports_attached_to_machine(self, name_or_id): + """List virtual ports attached to the bare metal machine. + + :param string name_or_id: A machine name or UUID. + :returns: List of ``munch.Munch`` representing the ports. + """ + machine = self.get_machine(name_or_id) + vif_ids = self.baremetal.list_node_vifs(machine) + return [self.get_port(vif) for vif in vif_ids] + def validate_node(self, uuid): # TODO(TheJulia): There are soooooo many other interfaces # that we can support validating, while these are essential, diff --git a/openstack/tests/functional/baremetal/test_baremetal_node.py b/openstack/tests/functional/baremetal/test_baremetal_node.py index 0d44264ad..a9e005b97 100644 --- a/openstack/tests/functional/baremetal/test_baremetal_node.py +++ b/openstack/tests/functional/baremetal/test_baremetal_node.py @@ -68,3 +68,34 @@ class TestBareMetalNode(base.BaseBaremetalTest): ignore_missing=False) self.assertIsNone(self.conn.baremetal.find_node(uuid)) self.assertIsNone(self.conn.baremetal.delete_node(uuid)) + + +class TestBareMetalVif(base.BaseBaremetalTest): + + min_microversion = '1.28' + + def setUp(self): + super(TestBareMetalVif, self).setUp() + self.node = self.create_node(network_interface='noop') + self.vif_id = "200712fc-fdfb-47da-89a6-2d19f76c7618" + + def test_node_vif_attach_detach(self): + self.conn.baremetal.attach_vif_to_node(self.node, self.vif_id) + # NOTE(dtantsur): The noop networking driver is completely noop - the + # VIF list does not return anything of value. + self.conn.baremetal.list_node_vifs(self.node) + res = self.conn.baremetal.detach_vif_from_node(self.node, self.vif_id, + ignore_missing=False) + self.assertTrue(res) + + def test_node_vif_negative(self): + uuid = "5c9dcd04-2073-49bc-9618-99ae634d8971" + self.assertRaises(exceptions.NotFoundException, + self.conn.baremetal.attach_vif_to_node, + uuid, self.vif_id) + self.assertRaises(exceptions.NotFoundException, + self.conn.baremetal.list_node_vifs, + uuid) + self.assertRaises(exceptions.NotFoundException, + self.conn.baremetal.detach_vif_from_node, + uuid, self.vif_id, ignore_missing=False) diff --git a/openstack/tests/unit/baremetal/v1/test_node.py b/openstack/tests/unit/baremetal/v1/test_node.py index 3629b1adc..e965590f9 100644 --- a/openstack/tests/unit/baremetal/v1/test_node.py +++ b/openstack/tests/unit/baremetal/v1/test_node.py @@ -13,6 +13,7 @@ from keystoneauth1 import adapter import mock +from openstack.baremetal.v1 import _common from openstack.baremetal.v1 import node from openstack import exceptions from openstack.tests.unit import base @@ -371,3 +372,63 @@ class TestNodeCreate(base.TestCase): headers=mock.ANY, microversion=self.session.default_microversion) mock_prov.assert_called_once_with(self.node, self.session, 'manage', wait=True) + + +@mock.patch.object(exceptions, 'raise_from_response', mock.Mock()) +@mock.patch.object(node.Node, '_get_session', lambda self, x: x) +class TestNodeVif(base.TestCase): + + def setUp(self): + super(TestNodeVif, self).setUp() + self.session = mock.Mock(spec=adapter.Adapter) + self.session.default_microversion = '1.28' + self.node = node.Node(id='c29db401-b6a7-4530-af8e-20a720dee946', + driver=FAKE['driver']) + self.vif_id = '714bdf6d-2386-4b5e-bd0d-bc036f04b1ef' + + def test_attach_vif(self): + self.assertIsNone(self.node.attach_vif(self.session, self.vif_id)) + self.session.post.assert_called_once_with( + 'nodes/%s/vifs' % self.node.id, json={'id': self.vif_id}, + headers=mock.ANY, microversion='1.28', + retriable_status_codes=[503]) + + def test_detach_vif_existing(self): + self.assertTrue(self.node.detach_vif(self.session, self.vif_id)) + self.session.delete.assert_called_once_with( + 'nodes/%s/vifs/%s' % (self.node.id, self.vif_id), + headers=mock.ANY, microversion='1.28', + retriable_status_codes=_common.RETRIABLE_STATUS_CODES) + + def test_detach_vif_missing(self): + self.session.delete.return_value.status_code = 400 + self.assertFalse(self.node.detach_vif(self.session, self.vif_id)) + self.session.delete.assert_called_once_with( + 'nodes/%s/vifs/%s' % (self.node.id, self.vif_id), + headers=mock.ANY, microversion='1.28', + retriable_status_codes=_common.RETRIABLE_STATUS_CODES) + + def test_list_vifs(self): + self.session.get.return_value.json.return_value = { + 'vifs': [ + {'id': '1234'}, + {'id': '5678'}, + ] + } + res = self.node.list_vifs(self.session) + self.assertEqual(['1234', '5678'], res) + self.session.get.assert_called_once_with( + 'nodes/%s/vifs' % self.node.id, + headers=mock.ANY, microversion='1.28') + + def test_incompatible_microversion(self): + self.session.default_microversion = '1.1' + self.assertRaises(exceptions.NotSupported, + self.node.attach_vif, + self.session, self.vif_id) + self.assertRaises(exceptions.NotSupported, + self.node.detach_vif, + self.session, self.vif_id) + self.assertRaises(exceptions.NotSupported, + self.node.list_vifs, + self.session) diff --git a/openstack/tests/unit/base.py b/openstack/tests/unit/base.py index 2b115a1ce..8062486bf 100644 --- a/openstack/tests/unit/base.py +++ b/openstack/tests/unit/base.py @@ -689,7 +689,8 @@ class IronicTestCase(TestCase): self.uuid = str(uuid.uuid4()) self.name = self.getUniqueString('name') - def get_mock_url(self, resource=None, append=None, qs_elements=None): - return super(IronicTestCase, self).get_mock_url( - service_type='baremetal', interface='public', resource=resource, - append=append, base_url_append='v1', qs_elements=qs_elements) + def get_mock_url(self, **kwargs): + kwargs.setdefault('service_type', 'baremetal') + kwargs.setdefault('interface', 'public') + kwargs.setdefault('base_url_append', 'v1') + return super(IronicTestCase, self).get_mock_url(**kwargs) diff --git a/openstack/tests/unit/cloud/test_baremetal_node.py b/openstack/tests/unit/cloud/test_baremetal_node.py index 83ac06b77..da1ae81c1 100644 --- a/openstack/tests/unit/cloud/test_baremetal_node.py +++ b/openstack/tests/unit/cloud/test_baremetal_node.py @@ -1609,6 +1609,88 @@ class TestBaremetalNode(base.IronicTestCase): self.assert_calls() + def test_attach_port_to_machine(self): + vif_id = '953ccbee-e854-450f-95fe-fe5e40d611ec' + self.register_uris([ + dict( + method='GET', + uri=self.get_mock_url( + resource='nodes', + append=[self.fake_baremetal_node['uuid']]), + json=self.fake_baremetal_node), + dict( + method='GET', + uri=self.get_mock_url( + service_type='network', + resource='ports.json', + base_url_append='v2.0'), + json={'ports': [{'id': vif_id}]}), + dict( + method='POST', + uri=self.get_mock_url( + resource='nodes', + append=[self.fake_baremetal_node['uuid'], 'vifs'])), + ]) + self.cloud.attach_port_to_machine(self.fake_baremetal_node['uuid'], + vif_id) + self.assert_calls() + + def test_detach_port_from_machine(self): + vif_id = '953ccbee-e854-450f-95fe-fe5e40d611ec' + self.register_uris([ + dict( + method='GET', + uri=self.get_mock_url( + resource='nodes', + append=[self.fake_baremetal_node['uuid']]), + json=self.fake_baremetal_node), + dict( + method='GET', + uri=self.get_mock_url( + service_type='network', + resource='ports.json', + base_url_append='v2.0'), + json={'ports': [{'id': vif_id}]}), + dict( + method='DELETE', + uri=self.get_mock_url( + resource='nodes', + append=[self.fake_baremetal_node['uuid'], 'vifs', + vif_id])), + ]) + self.cloud.detach_port_from_machine(self.fake_baremetal_node['uuid'], + vif_id) + self.assert_calls() + + def test_list_ports_attached_to_machine(self): + vif_id = '953ccbee-e854-450f-95fe-fe5e40d611ec' + fake_port = {'id': vif_id, 'name': 'test'} + self.register_uris([ + dict( + method='GET', + uri=self.get_mock_url( + resource='nodes', + append=[self.fake_baremetal_node['uuid']]), + json=self.fake_baremetal_node), + dict( + method='GET', + uri=self.get_mock_url( + resource='nodes', + append=[self.fake_baremetal_node['uuid'], 'vifs']), + json={'vifs': [{'id': vif_id}]}), + dict( + method='GET', + uri=self.get_mock_url( + service_type='network', + resource='ports.json', + base_url_append='v2.0'), + json={'ports': [fake_port]}), + ]) + res = self.cloud.list_ports_attached_to_machine( + self.fake_baremetal_node['uuid']) + self.assert_calls() + self.assertEqual([fake_port], res) + class TestUpdateMachinePatch(base.IronicTestCase): # NOTE(TheJulia): As appears, and mordred describes, diff --git a/releasenotes/notes/baremetal-vif-122457118c722a9b.yaml b/releasenotes/notes/baremetal-vif-122457118c722a9b.yaml new file mode 100644 index 000000000..061d703a1 --- /dev/null +++ b/releasenotes/notes/baremetal-vif-122457118c722a9b.yaml @@ -0,0 +1,4 @@ +--- +features: + - | + Implements VIF attach/detach API for bare metal nodes.