diff --git a/requirements.txt b/requirements.txt index d2edd2eb1..dc2950d2c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,7 @@ pbr>=0.11,<2.0 bunch +decorator jsonpatch os-client-config>=1.2.0 six diff --git a/shade/__init__.py b/shade/__init__.py index 1ff3b9d35..ebcbcfb07 100644 --- a/shade/__init__.py +++ b/shade/__init__.py @@ -13,11 +13,13 @@ # limitations under the License. import hashlib +import inspect import logging import operator import os from cinderclient.v1 import client as cinder_client +from decorator import decorator from dogpile import cache import glanceclient import glanceclient.exc @@ -60,6 +62,32 @@ OBJECT_CONTAINER_ACLS = { } +def valid_kwargs(*valid_args): + # This decorator checks if argument passed as **kwargs to a function are + # present in valid_args. + # + # Typically, valid_kwargs is used when we want to distinguish between + # None and omitted arguments and we still want to validate the argument + # list. + # + # Example usage: + # + # @valid_kwargs('opt_arg1', 'opt_arg2') + # def my_func(self, mandatory_arg1, mandatory_arg2, **kwargs): + # ... + # + @decorator + def func_wrapper(func, *args, **kwargs): + argspec = inspect.getargspec(func) + for k in kwargs: + if k not in argspec.args[1:] and k not in valid_args: + raise TypeError( + "{f}() got an unexpected keyword argument " + "'{arg}'".format(f=inspect.stack()[1][3], arg=k)) + return func(*args, **kwargs) + return func_wrapper + + def openstack_clouds(config=None, debug=False): if not config: config = os_client_config.OpenStackConfig() @@ -732,6 +760,10 @@ class OpenStackCloud(object): subnets = self.list_subnets() return _utils._filter_list(subnets, name_or_id, filters) + def search_ports(self, name_or_id=None, filters=None): + ports = self.list_ports() + return _utils._filter_list(ports, name_or_id, filters) + def search_volumes(self, name_or_id=None, filters=None): volumes = self.list_volumes() return _utils._filter_list(volumes, name_or_id, filters) @@ -780,6 +812,16 @@ class OpenStackCloud(object): raise OpenStackCloudException( "Error fetching subnet list: %s" % e) + def list_ports(self): + try: + return self.manager.submitTask(_tasks.PortList())['ports'] + except Exception as e: + self.log.debug( + "neutron could not list ports: {msg}".format( + msg=str(e)), exc_info=True) + raise OpenStackCloudException( + "error fetching port list: {msg}".format(msg=str(e))) + @_cache_on_arguments(should_cache_fn=_no_pending_volumes) def list_volumes(self, cache=True): if not cache: @@ -948,6 +990,9 @@ class OpenStackCloud(object): def get_subnet(self, name_or_id, filters=None): return _utils._get_entity(self.search_subnets, name_or_id, filters) + def get_port(self, name_or_id, filters=None): + return _utils._get_entity(self.search_ports, name_or_id, filters) + def get_volume(self, name_or_id, filters=None): return _utils._get_entity(self.search_volumes, name_or_id, filters) @@ -2163,6 +2208,125 @@ class OpenStackCloud(object): # a dict). return new_subnet['subnet'] + @valid_kwargs('name', 'admin_state_up', 'mac_address', 'fixed_ips', + 'subnet_id', 'ip_address', 'security_groups', + 'allowed_address_pairs', 'extra_dhcp_opts', 'device_owner', + 'device_id') + def create_port(self, network_id, **kwargs): + """Create a port + + :param network_id: The ID of the network. (Required) + :param name: A symbolic name for the port. (Optional) + :param admin_state_up: The administrative status of the port, + which is up (true, default) or down (false). (Optional) + :param mac_address: The MAC address. (Optional) + :param fixed_ips: If you specify only a subnet ID, OpenStack Networking + allocates an available IP from that subnet to the port. (Optional) + :param subnet_id: If you specify only a subnet ID, OpenStack Networking + allocates an available IP from that subnet to the port. (Optional) + If you specify both a subnet ID and an IP address, OpenStack + Networking tries to allocate the specified address to the port. + :param ip_address: If you specify both a subnet ID and an IP address, + OpenStack Networking tries to allocate the specified address to + the port. + :param security_groups: List of security group UUIDs. (Optional) + :param allowed_address_pairs: Allowed address pairs list (Optional) + For example:: + + [ + { + "ip_address": "23.23.23.1", + "mac_address": "fa:16:3e:c4:cd:3f" + }, ... + ] + :param extra_dhcp_opts: Extra DHCP options. (Optional). + For example:: + + [ + { + "opt_name": "opt name1", + "opt_value": "value1" + }, ... + ] + :param device_owner: The ID of the entity that uses this port. + For example, a DHCP agent. (Optional) + :param device_id: The ID of the device that uses this port. + For example, a virtual server. (Optional) + + :returns: a dictionary describing the created port. + + :raises: ``OpenStackCloudException`` on operation error. + """ + kwargs['network_id'] = network_id + + try: + return self.manager.submitTask( + _tasks.PortCreate(body={'port': kwargs}))['port'] + except Exception as e: + self.log.debug("failed to create a new port for network" + "'{net}'".format(net=network_id), + exc_info=True) + raise OpenStackCloudException( + "error creating a new port for network " + "'{net}': {msg}".format(net=network_id, msg=str(e))) + + @valid_kwargs('name', 'admin_state_up', 'fixed_ips', 'security_groups') + def update_port(self, name_or_id, **kwargs): + """Update a port + + Note: to unset an attribute use None value. To leave an attribute + untouched just omit it. + + :param name_or_id: name or id of the port to update. (Required) + :param name: A symbolic name for the port. (Optional) + :param admin_state_up: The administrative status of the port, + which is up (true) or down (false). (Optional) + :param fixed_ips: If you specify only a subnet ID, OpenStack Networking + allocates an available IP from that subnet to the port. (Optional) + :param security_groups: List of security group UUIDs. (Optional) + + :returns: a dictionary describing the updated port. + + :raises: OpenStackCloudException on operation error. + """ + port = self.get_port(name_or_id=name_or_id) + if port is None: + raise OpenStackCloudException( + "failed to find port '{port}'".format(port=name_or_id)) + + try: + return self.manager.submitTask( + _tasks.PortUpdate( + port=port['id'], body={'port': kwargs}))['port'] + except Exception as e: + self.log.debug("failed to update port '{port}'".format( + port=name_or_id), exc_info=True) + raise OpenStackCloudException( + "failed to update port '{port}': {msg}".format( + port=name_or_id, msg=str(e))) + + def delete_port(self, name_or_id): + """Delete a port + + :param name_or_id: id or name of the port to delete. + + :returns: None. + + :raises: OpenStackCloudException on operation error. + """ + port = self.get_port(name_or_id=name_or_id) + if port is None: + return + + try: + self.manager.submitTask(_tasks.PortDelete(port=port['id'])) + except Exception as e: + self.log.debug("failed to delete port '{port}'".format( + port=name_or_id), exc_info=True) + raise OpenStackCloudException( + "failed to delete port '{port}': {msg}".format( + port=name_or_id, msg=str(e))) + class OperatorCloud(OpenStackCloud): """Represent a privileged/operator connection to an OpenStack Cloud. diff --git a/shade/_tasks.py b/shade/_tasks.py index 07c01df74..9d713e91d 100644 --- a/shade/_tasks.py +++ b/shade/_tasks.py @@ -283,6 +283,26 @@ class SubnetUpdate(task_manager.Task): return client.neutron_client.update_subnet(**self.args) +class PortList(task_manager.Task): + def main(self, client): + return client.neutron_client.list_ports(**self.args) + + +class PortCreate(task_manager.Task): + def main(self, client): + return client.neutron_client.create_port(**self.args) + + +class PortUpdate(task_manager.Task): + def main(self, client): + return client.neutron_client.update_port(**self.args) + + +class PortDelete(task_manager.Task): + def main(self, client): + return client.neutron_client.delete_port(**self.args) + + class MachineCreate(task_manager.Task): def main(self, client): return client.ironic_client.node.create(**self.args) diff --git a/shade/tests/functional/test_port.py b/shade/tests/functional/test_port.py new file mode 100644 index 000000000..82ca096c6 --- /dev/null +++ b/shade/tests/functional/test_port.py @@ -0,0 +1,136 @@ +# Copyright (c) 2015 Hewlett-Packard Development Company, L.P. +# +# 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. + +""" +test_port +---------------------------------- + +Functional tests for `shade` port resource. +""" + +import string +import random + +from shade import openstack_cloud +from shade.exc import OpenStackCloudException +from shade.tests import base + + +class TestPort(base.TestCase): + + def setUp(self): + super(TestPort, self).setUp() + # Shell should have OS-* envvars from openrc, typically loaded by job + self.cloud = openstack_cloud() + # Skip Neutron tests if neutron is not present + if not self.cloud.has_service('network'): + self.skipTest('Network service not supported by cloud') + + # Generate a unique port name to allow concurrent tests + self.new_port_name = 'test_' + ''.join( + random.choice(string.ascii_lowercase) for _ in range(5)) + + self.addCleanup(self._cleanup_ports) + + def _cleanup_ports(self): + exception_list = list() + + for p in self.cloud.list_ports(): + if p['name'].startswith(self.new_port_name): + try: + self.cloud.delete_port(name_or_id=p['id']) + except Exception as e: + # We were unable to delete this port, let's try with next + exception_list.append(e) + continue + + if exception_list: + # Raise an error: we must make users aware that something went + # wrong + raise OpenStackCloudException('\n'.join(exception_list)) + + def test_create_port(self): + port_name = self.new_port_name + '_create' + + networks = self.cloud.list_networks() + if not networks: + self.assertFalse('no sensible network available') + + port = self.cloud.create_port( + network_id=networks[0]['id'], name=port_name) + self.assertIsInstance(port, dict) + self.assertTrue('id' in port) + self.assertEqual(port.get('name'), port_name) + + def test_get_port(self): + port_name = self.new_port_name + '_get' + + networks = self.cloud.list_networks() + if not networks: + self.assertFalse('no sensible network available') + + port = self.cloud.create_port( + network_id=networks[0]['id'], name=port_name) + self.assertIsInstance(port, dict) + self.assertTrue('id' in port) + self.assertEqual(port.get('name'), port_name) + + updated_port = self.cloud.get_port(name_or_id=port['id']) + # extra_dhcp_opts is added later by Neutron... + if 'extra_dhcp_opts' in updated_port: + del updated_port['extra_dhcp_opts'] + self.assertEqual(port, updated_port) + + def test_update_port(self): + port_name = self.new_port_name + '_update' + new_port_name = port_name + '_new' + + networks = self.cloud.list_networks() + if not networks: + self.assertFalse('no sensible network available') + + self.cloud.create_port( + network_id=networks[0]['id'], name=port_name) + + port = self.cloud.update_port(name_or_id=port_name, + name=new_port_name) + self.assertIsInstance(port, dict) + self.assertEqual(port.get('name'), new_port_name) + + updated_port = self.cloud.get_port(name_or_id=port['id']) + self.assertEqual(port.get('name'), new_port_name) + self.assertEqual(port, updated_port) + + def test_delete_port(self): + port_name = self.new_port_name + '_delete' + + networks = self.cloud.list_networks() + if not networks: + self.assertFalse('no sensible network available') + + port = self.cloud.create_port( + network_id=networks[0]['id'], name=port_name) + self.assertIsInstance(port, dict) + self.assertTrue('id' in port) + self.assertEqual(port.get('name'), port_name) + + updated_port = self.cloud.get_port(name_or_id=port['id']) + self.assertIsNotNone(updated_port) + + self.cloud.delete_port(name_or_id=port_name) + + updated_port = self.cloud.get_port(name_or_id=port['id']) + self.assertIsNone(updated_port) diff --git a/shade/tests/unit/test_port.py b/shade/tests/unit/test_port.py new file mode 100644 index 000000000..ca3b866c9 --- /dev/null +++ b/shade/tests/unit/test_port.py @@ -0,0 +1,268 @@ +# Copyright (c) 2015 Hewlett-Packard Development Company, L.P. +# +# 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. + +""" +test_port +---------------------------------- + +Test port resource (managed by neutron) +""" + +from mock import patch +from shade import OpenStackCloud +from shade.exc import OpenStackCloudException +from shade.tests.unit import base + + +class TestPort(base.TestCase): + mock_neutron_port_create_rep = { + 'port': { + 'status': 'DOWN', + 'binding:host_id': '', + 'name': 'test-port-name', + 'allowed_address_pairs': [], + 'admin_state_up': True, + 'network_id': 'test-net-id', + 'tenant_id': 'test-tenant-id', + 'binding:vif_details': {}, + 'binding:vnic_type': 'normal', + 'binding:vif_type': 'unbound', + 'device_owner': '', + 'mac_address': '50:1c:0d:e4:f0:0d', + 'binding:profile': {}, + 'fixed_ips': [ + { + 'subnet_id': 'test-subnet-id', + 'ip_address': '29.29.29.29' + } + ], + 'id': 'test-port-id', + 'security_groups': [], + 'device_id': '' + } + } + + mock_neutron_port_update_rep = { + 'port': { + 'status': 'DOWN', + 'binding:host_id': '', + 'name': 'test-port-name-updated', + 'allowed_address_pairs': [], + 'admin_state_up': True, + 'network_id': 'test-net-id', + 'tenant_id': 'test-tenant-id', + 'binding:vif_details': {}, + 'binding:vnic_type': 'normal', + 'binding:vif_type': 'unbound', + 'device_owner': '', + 'mac_address': '50:1c:0d:e4:f0:0d', + 'binding:profile': {}, + 'fixed_ips': [ + { + 'subnet_id': 'test-subnet-id', + 'ip_address': '29.29.29.29' + } + ], + 'id': 'test-port-id', + 'security_groups': [], + 'device_id': '' + } + } + + mock_neutron_port_list_rep = { + 'ports': [ + { + 'status': 'ACTIVE', + 'binding:host_id': 'devstack', + 'name': 'first-port', + 'allowed_address_pairs': [], + 'admin_state_up': True, + 'network_id': '70c1db1f-b701-45bd-96e0-a313ee3430b3', + 'tenant_id': '', + 'extra_dhcp_opts': [], + 'binding:vif_details': { + 'port_filter': True, + 'ovs_hybrid_plug': True + }, + 'binding:vif_type': 'ovs', + 'device_owner': 'network:router_gateway', + 'mac_address': 'fa:16:3e:58:42:ed', + 'binding:profile': {}, + 'binding:vnic_type': 'normal', + 'fixed_ips': [ + { + 'subnet_id': '008ba151-0b8c-4a67-98b5-0d2b87666062', + 'ip_address': '172.24.4.2' + } + ], + 'id': 'd80b1a3b-4fc1-49f3-952e-1e2ab7081d8b', + 'security_groups': [], + 'device_id': '9ae135f4-b6e0-4dad-9e91-3c223e385824' + }, + { + 'status': 'ACTIVE', + 'binding:host_id': 'devstack', + 'name': '', + 'allowed_address_pairs': [], + 'admin_state_up': True, + 'network_id': 'f27aa545-cbdd-4907-b0c6-c9e8b039dcc2', + 'tenant_id': 'd397de8a63f341818f198abb0966f6f3', + 'extra_dhcp_opts': [], + 'binding:vif_details': { + 'port_filter': True, + 'ovs_hybrid_plug': True + }, + 'binding:vif_type': 'ovs', + 'device_owner': 'network:router_interface', + 'mac_address': 'fa:16:3e:bb:3c:e4', + 'binding:profile': {}, + 'binding:vnic_type': 'normal', + 'fixed_ips': [ + { + 'subnet_id': '288bf4a1-51ba-43b6-9d0a-520e9005db17', + 'ip_address': '10.0.0.1' + } + ], + 'id': 'f71a6703-d6de-4be1-a91a-a570ede1d159', + 'security_groups': [], + 'device_id': '9ae135f4-b6e0-4dad-9e91-3c223e385824' + } + ] + } + + def setUp(self): + super(TestPort, self).setUp() + self.client = OpenStackCloud('cloud', {}) + + @patch.object(OpenStackCloud, 'neutron_client') + def test_create_port(self, mock_neutron_client): + mock_neutron_client.create_port.return_value = \ + self.mock_neutron_port_create_rep + + port = self.client.create_port( + network_id='test-net-id', name='test-port-name', + admin_state_up=True) + + mock_neutron_client.create_port.assert_called_with( + body={'port': dict(network_id='test-net-id', name='test-port-name', + admin_state_up=True)}) + self.assertEqual(self.mock_neutron_port_create_rep['port'], port) + + def test_create_port_parameters(self): + """Test that we detect invalid arguments passed to create_port""" + self.assertRaises( + TypeError, self.client.create_port, + network_id='test-net-id', nome='test-port-name', + stato_amministrativo_porta=True) + + @patch.object(OpenStackCloud, 'neutron_client') + def test_create_port_exception(self, mock_neutron_client): + mock_neutron_client.create_port.side_effect = Exception('blah') + + self.assertRaises( + OpenStackCloudException, self.client.create_port, + network_id='test-net-id', name='test-port-name', + admin_state_up=True) + + @patch.object(OpenStackCloud, 'neutron_client') + def test_update_port(self, mock_neutron_client): + mock_neutron_client.list_ports.return_value = \ + self.mock_neutron_port_list_rep + mock_neutron_client.update_port.return_value = \ + self.mock_neutron_port_update_rep + + port = self.client.update_port( + name_or_id='d80b1a3b-4fc1-49f3-952e-1e2ab7081d8b', + name='test-port-name-updated') + + mock_neutron_client.update_port.assert_called_with( + port='d80b1a3b-4fc1-49f3-952e-1e2ab7081d8b', + body={'port': dict(name='test-port-name-updated')}) + self.assertEqual(self.mock_neutron_port_update_rep['port'], port) + + def test_update_port_parameters(self): + """Test that we detect invalid arguments passed to update_port""" + self.assertRaises( + TypeError, self.client.update_port, + name_or_id='test-port-id', nome='test-port-name-updated') + + @patch.object(OpenStackCloud, 'neutron_client') + def test_update_port_exception(self, mock_neutron_client): + mock_neutron_client.list_ports.return_value = \ + self.mock_neutron_port_list_rep + mock_neutron_client.update_port.side_effect = Exception('blah') + + self.assertRaises( + OpenStackCloudException, self.client.update_port, + name_or_id='d80b1a3b-4fc1-49f3-952e-1e2ab7081d8b', + name='test-port-name-updated') + + @patch.object(OpenStackCloud, 'neutron_client') + def test_list_ports(self, mock_neutron_client): + mock_neutron_client.list_ports.return_value = \ + self.mock_neutron_port_list_rep + + ports = self.client.list_ports() + + mock_neutron_client.list_ports.assert_called_with() + self.assertItemsEqual(self.mock_neutron_port_list_rep['ports'], ports) + + @patch.object(OpenStackCloud, 'neutron_client') + def test_list_ports_exception(self, mock_neutron_client): + mock_neutron_client.list_ports.side_effect = Exception('blah') + + self.assertRaises(OpenStackCloudException, self.client.list_ports) + + @patch.object(OpenStackCloud, 'neutron_client') + def test_search_ports_by_id(self, mock_neutron_client): + mock_neutron_client.list_ports.return_value = \ + self.mock_neutron_port_list_rep + + ports = self.client.search_ports( + name_or_id='f71a6703-d6de-4be1-a91a-a570ede1d159') + + mock_neutron_client.list_ports.assert_called_with() + self.assertEquals(1, len(ports)) + self.assertEquals('fa:16:3e:bb:3c:e4', ports[0]['mac_address']) + + @patch.object(OpenStackCloud, 'neutron_client') + def test_search_ports_by_name(self, mock_neutron_client): + mock_neutron_client.list_ports.return_value = \ + self.mock_neutron_port_list_rep + + ports = self.client.search_ports(name_or_id='first-port') + + mock_neutron_client.list_ports.assert_called_with() + self.assertEquals(1, len(ports)) + self.assertEquals('fa:16:3e:58:42:ed', ports[0]['mac_address']) + + @patch.object(OpenStackCloud, 'neutron_client') + def test_search_ports_not_found(self, mock_neutron_client): + mock_neutron_client.list_ports.return_value = \ + self.mock_neutron_port_list_rep + + ports = self.client.search_ports(name_or_id='non-existent') + + mock_neutron_client.list_ports.assert_called_with() + self.assertEquals(0, len(ports)) + + @patch.object(OpenStackCloud, 'neutron_client') + def test_delete_port(self, mock_neutron_client): + mock_neutron_client.list_ports.return_value = \ + self.mock_neutron_port_list_rep + + self.client.delete_port(name_or_id='first-port') + + mock_neutron_client.delete_port.assert_called_with( + port='d80b1a3b-4fc1-49f3-952e-1e2ab7081d8b')