diff --git a/kuryr/controllers.py b/kuryr/controllers.py index c9dcdfe1..076e46ea 100644 --- a/kuryr/controllers.py +++ b/kuryr/controllers.py @@ -14,6 +14,8 @@ import os from flask import jsonify from flask import request +import netaddr +from neutronclient.common import exceptions as n_exceptions from kuryr import app from kuryr.constants import SCHEMA @@ -37,9 +39,138 @@ if OS_USERNAME and OS_PASSWORD: else: app.neutron = utils.get_neutron_client_simple(url=OS_URL, token=OS_TOKEN) +# TODO(tfukushima): Retrieve the following subnet names from the config file. +SUBNET_POOLS_V4 = [ + p.strip() for p in os.environ.get('SUBNET_POOLS_V4', 'kuryr').split(',')] +SUBNET_POOLS_V6 = [ + p.strip() for p in os.environ.get('SUBNET_POOLS_V6', 'kuryr6').split(',')] + app.neutron.format = 'json' +def _get_subnets_by_attrs(**attrs): + subnets = app.neutron.list_subnets(**attrs) + if len(subnets) > 1: + raise exceptions.DuplicatedResourceException( + "Multiple Neutron subnets exist for the params {0} " + .format(', '.join(['{0}={1}'.format(k, v) + for k, v in attrs.items()]))) + return subnets['subnets'] + + +def _handle_allocation_from_pools(neutron_network_id, existing_subnets): + for v4_subnet_name in SUBNET_POOLS_V4: + v4_subnets = _get_subnets_by_attrs( + network_id=neutron_network_id, name=v4_subnet_name) + existing_subnets += v4_subnets + + for v6_subnet_name in SUBNET_POOLS_V6: + v6_subnets = _get_subnets_by_attrs( + network_id=neutron_network_id, name=v6_subnet_name) + existing_subnets += v6_subnets + + +def _process_subnet(neutron_network_id, endpoint_id, interface_cidr, + new_subnets, existing_subnets): + subnets = _get_subnets_by_attrs( + network_id=neutron_network_id, cidr=interface_cidr) + if subnets: + existing_subnets += subnets + else: + cidr = netaddr.IPNetwork(interface_cidr) + subnet_network = str(cidr.network) + subnet_cidr = '/'.join([subnet_network, + str(cidr.prefixlen)]) + new_subnets.append({ + 'name': '-'.join([endpoint_id, subnet_network]), + # Allocate all IP addresses in the subnet. + 'allocation_pools': None, + 'network_id': neutron_network_id, + 'ip_version': cidr.version, + 'cidr': subnet_cidr, + }) + + +def _handle_explicit_allocation(neutron_network_id, endpoint_id, + interface_cidrv4, interface_cidrv6, + new_subnets, existing_subnets): + if interface_cidrv4: + _process_subnet(neutron_network_id, endpoint_id, interface_cidrv4, + new_subnets, existing_subnets) + + if interface_cidrv6: + _process_subnet(neutron_network_id, endpoint_id, interface_cidrv6, + new_subnets, existing_subnets) + + if new_subnets: + # Bulk create operation of subnets + created_subnets = app.neutron.create_subnet({'subnets': new_subnets}) + + return created_subnets + + +def _create_subnets_and_or_port(interfaces, neutron_network_id, endpoint_id): + response_interfaces = [] + for interface in interfaces: + existing_subnets = [] + created_subnets = {} + # v4 and v6 Subnets for bulk creation. + new_subnets = [] + + interface_id = interface['ID'] + interface_cidrv4 = interface.get('Address', '') + interface_cidrv6 = interface.get('AddressIPv6', '') + interface_mac = interface['MacAddress'] + + if interface_cidrv4 or interface_cidrv6: + created_subnets = _handle_explicit_allocation( + neutron_network_id, endpoint_id, interface_cidrv4, + interface_cidrv6, new_subnets, existing_subnets) + else: + _handle_allocation_from_pools( + neutron_network_id, existing_subnets) + + try: + port = { + 'name': '-'.join([endpoint_id, str(interface_id), 'port']), + 'admin_state_up': True, + 'mac_address': interface_mac, + 'network_id': neutron_network_id, + } + created_subnets = created_subnets.get('subnets', []) + all_subnets = created_subnets + existing_subnets + fixed_ips = port['fixed_ips'] = [] + for subnet in all_subnets: + fixed_ip = {'subnet_id': subnet['id']} + if subnet['ip_version'] == 4: + cidr = netaddr.IPNetwork(interface_cidrv4) + else: + cidr = netaddr.IPNetwork(interface_cidrv6) + subnet_cidr = '/'.join([str(cidr.network), + str(cidr.prefixlen)]) + if subnet['cidr'] != subnet_cidr: + continue + fixed_ip['ip_address'] = str(cidr.ip) + fixed_ips.append(fixed_ip) + app.neutron.create_port({'port': port}) + + response_interfaces.append({ + 'ID': interface_id, + 'Address': interface_cidrv4, + 'AddressIPv6': interface_cidrv6, + 'MacAddress': interface_mac + }) + except n_exceptions.NeutronClientException as ex: + app.logger.error("Error happend during creating a " + "Neutron port: {0}".format(ex)) + # Rollback the subnets creation + for subnet in created_subnets: + app.neutron.delete_subnet(subnet['id']) + raise + + return response_interfaces + + @app.route('/Plugin.Activate', methods=['POST']) def plugin_activate(): return jsonify(SCHEMA['PLUGIN_ACTIVATE']) @@ -122,7 +253,55 @@ def network_driver_delete_network(): @app.route('/NetworkDriver.CreateEndpoint', methods=['POST']) def network_driver_create_endpoint(): - return jsonify(SCHEMA['CREATE_ENDPOINT']) + """Creates new Neutron Subnets and a Port with the given EndpointID. + + This function takes the following JSON data and delegates the actual + endpoint creation to the Neutron client mapping it into Subnet and Port. :: + + { + "NetworkID": string, + "EndpointID": string, + "Options": { + ... + }, + "Interfaces": [{ + "ID": int, + "Address": string, + "AddressIPv6": string, + "MacAddress": string + }, ...] + } + + See the following link for more details about the spec: + + https://github.com/docker/libnetwork/blob/master/docs/remote.md#create-endpoint # noqa + """ + json_data = request.get_json(force=True) + + app.logger.debug("Received JSON data {0} for /NetworkDriver.CreateEndpoint" + .format(json_data)) + # TODO(tfukushima): Add a validation of the JSON data for the subnet. + neutron_network_name = json_data['NetworkID'] + endpoint_id = json_data['EndpointID'] + + filtered_networks = app.neutron.list_networks(name=neutron_network_name) + + if not filtered_networks: + return jsonify({ + 'Err': "Neutron network associated with ID {0} doesn't exit." + .format(neutron_network_name) + }) + elif len(filtered_networks) > 1: + raise exceptions.DuplicatedResourceException( + "Multiple Neutron Networks exist for NetworkID {0}" + .format(neutron_network_name)) + else: + neutron_network_id = filtered_networks['networks'][0]['id'] + interfaces = json_data['Interfaces'] + response_interfaces = _create_subnets_and_or_port( + interfaces, neutron_network_id, endpoint_id) + + return jsonify({'Interfaces': response_interfaces}) @app.route('/NetworkDriver.EndpointOperInfo', methods=['POST']) diff --git a/kuryr/tests/test_kuryr.py b/kuryr/tests/test_kuryr.py index 2e67d26a..ddd5ea6b 100644 --- a/kuryr/tests/test_kuryr.py +++ b/kuryr/tests/test_kuryr.py @@ -41,7 +41,6 @@ class TestKuryr(TestKuryrBase): - POST /NetworkDriver.Leave """ @data(('/Plugin.Activate', SCHEMA['PLUGIN_ACTIVATE']), - ('/NetworkDriver.CreateEndpoint', SCHEMA['CREATE_ENDPOINT']), ('/NetworkDriver.EndpointOperInfo', SCHEMA['ENDPOINT_OPER_INFO']), ('/NetworkDriver.DeleteEndpoint', SCHEMA['SUCCESS']), ('/NetworkDriver.Join', SCHEMA['JOIN']), @@ -108,3 +107,147 @@ class TestKuryr(TestKuryrBase): self.assertEqual(200, response.status_code) decoded_json = jsonutils.loads(response.data) self.assertEqual(SCHEMA['SUCCESS'], decoded_json) + + def test_network_driver_create_endpoint(self): + docker_network_id = hashlib.sha256( + str(random.getrandbits(256))).hexdigest() + docker_endpoint_id = hashlib.sha256( + str(random.getrandbits(256))).hexdigest() + + fake_neutron_network_id = str(uuid.uuid4()) + self._mock_out_network(fake_neutron_network_id, docker_network_id) + + self.mox.StubOutWithMock(app.neutron, 'list_subnets') + fake_existing_subnets_response = { + "subnets": [] + } + fake_cidr_v4 = '192.168.1.2/24' + app.neutron.list_subnets( + network_id=fake_neutron_network_id, + cidr=fake_cidr_v4).AndReturn(fake_existing_subnets_response) + + fake_cidr_v6 = 'fe80::f816:3eff:fe20:57c4/64' + app.neutron.list_subnets( + network_id=fake_neutron_network_id, + cidr=fake_cidr_v6).AndReturn(fake_existing_subnets_response) + + self.mox.StubOutWithMock(app.neutron, 'create_subnet') + fake_subnet_request = { + "subnets": [{ + 'name': '-'.join([docker_endpoint_id, + '192.168.1.0']), + 'network_id': fake_neutron_network_id, + 'allocation_pools': None, + 'ip_version': 4, + "cidr": '192.168.1.0/24' + }, { + 'name': '-'.join([docker_endpoint_id, + 'fe80::']), + 'network_id': fake_neutron_network_id, + 'allocation_pools': None, + 'ip_version': 6, + "cidr": 'fe80::/64' + }] + } + # The following fake response is retrieved from the Neutron doc: + # http://developer.openstack.org/api-ref-networking-v2.html#createSubnet # noqa + subnet_v4_id = "9436e561-47bf-436a-b1f1-fe23a926e031" + subnet_v6_id = "64dd4a98-3d7a-4bfd-acf4-91137a8d2f51" + fake_subnet_response = { + "subnets": [{ + "name": '-'.join([docker_endpoint_id, + '192.168.1.0']), + "network_id": docker_network_id, + "tenant_id": "c1210485b2424d48804aad5d39c61b8f", + "allocation_pools": [{ + "start": "192.168.1.2", + "end": "192.168.1.254" + }], + "gateway_ip": "192.168.1.1", + "ip_version": 4, + "cidr": '192.168.1.0/24', + "id": subnet_v4_id, + "enable_dhcp": True + }, { + "name": '-'.join([docker_endpoint_id, + 'fe80::']), + "network_id": docker_network_id, + "tenant_id": "c1210485b2424d48804aad5d39c61b8f", + "allocation_pools": [{ + "start": "fe80::f816:3eff:fe20:57c4", + "end": "fe80::ffff:ffff:ffff:ffff" + }], + "gateway_ip": "fe80::f816:3eff:fe20:57c3", + "ip_version": 6, + "cidr": 'fe80::/64', + "id": subnet_v6_id, + "enable_dhcp": True + }] + } + app.neutron.create_subnet( + fake_subnet_request).AndReturn(fake_subnet_response) + + subnet_v4_address = fake_cidr_v4.split('/')[0] + subnet_v6_address = fake_cidr_v6.split('/')[0] + self.mox.StubOutWithMock(app.neutron, 'create_port') + fake_port_request = { + 'port': { + 'name': '-'.join([docker_endpoint_id, '0', 'port']), + 'admin_state_up': True, + 'mac_address': "fa:16:3e:20:57:c3", + 'network_id': fake_neutron_network_id, + 'fixed_ips': [{ + 'subnet_id': subnet_v4_id, + 'ip_address': subnet_v4_address + }, { + 'subnet_id': subnet_v6_id, + 'ip_address': subnet_v6_address + }] + } + } + # The following fake response is retrieved from the Neutron doc: + # http://developer.openstack.org/api-ref-networking-v2.html#createPort # noqa + fake_port = { + "port": { + "status": "DOWN", + "name": '-'.join([docker_endpoint_id, '0', 'port']), + "allowed_address_pairs": [], + "admin_state_up": True, + "network_id": fake_neutron_network_id, + "tenant_id": "d6700c0c9ffa4f1cb322cd4a1f3906fa", + "device_owner": "", + "mac_address": "fa:16:3e:20:57:c3", + "fixed_ips": [{ + 'subnet_id': subnet_v4_id, + 'ip_address': subnet_v4_address + }, { + 'subnet_id': subnet_v6_id, + 'ip_address': subnet_v6_address + }], + "id": "65c0ee9f-d634-4522-8954-51021b570b0d", + "security_groups": [], + "device_id": "" + } + } + app.neutron.create_port(fake_port_request).AndReturn(fake_port) + self.mox.ReplayAll() + + data = { + 'NetworkID': docker_network_id, + 'EndpointID': docker_endpoint_id, + 'Options': {}, + 'Interfaces': [{ + 'ID': 0, + 'Address': '192.168.1.2/24', + 'AddressIPv6': 'fe80::f816:3eff:fe20:57c4/64', + 'MacAddress': "fa:16:3e:20:57:c3" + }] + } + response = self.app.post('/NetworkDriver.CreateEndpoint', + content_type='application/json', + data=jsonutils.dumps(data)) + + self.assertEqual(200, response.status_code) + decoded_json = jsonutils.loads(response.data) + expected = {'Interfaces': data['Interfaces']} + self.assertEqual(expected, decoded_json) diff --git a/kuryr/tests/test_kuryr_endpoint.py b/kuryr/tests/test_kuryr_endpoint.py new file mode 100644 index 00000000..889f5016 --- /dev/null +++ b/kuryr/tests/test_kuryr_endpoint.py @@ -0,0 +1,247 @@ +# 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. + +import hashlib +import random +import uuid + +from ddt import data +from ddt import ddt +from neutronclient.common import exceptions +from oslo_serialization import jsonutils + +from kuryr import app +from kuryr.tests.base import TestKuryrFailures + + +@ddt +class TestKuryrEndpointCreateFailures(TestKuryrFailures): + """Unittests for the failures for creating endpoints. + + This test covers error responses listed in the spec: + http://developer.openstack.org/api-ref-networking-v2.html#createSubnet # noqa + http://developer.openstack.org/api-ref-networking-v2-ext.html#createPort # noqa + """ + @staticmethod + def _get_fake_subnets(docker_endpoint_id, neutron_network_id, + fake_neutron_subnet1_id, fake_neutron_subnet2_id): + # The following fake response is retrieved from the Neutron doc: + # http://developer.openstack.org/api-ref-networking-v2.html#createSubnet # noqa + fake_subnet_response = { + "subnets": [{ + "name": '-'.join([docker_endpoint_id, '192.168.1.0']), + "network_id": neutron_network_id, + "tenant_id": "c1210485b2424d48804aad5d39c61b8f", + "allocation_pools": [{"start": "192.168.1.2", + "end": "192.168.1.254"}], + "gateway_ip": "192.168.1.1", + "ip_version": 4, + "cidr": "192.168.1.0/24", + "id": fake_neutron_subnet1_id, + "enable_dhcp": True + }, { + "name": '-'.join([docker_endpoint_id, 'fe80::']), + "network_id": neutron_network_id, + "tenant_id": "c1210485b2424d48804aad5d39c61b8f", + "allocation_pools": [{"start": "fe80::f816:3eff:fe20:57c4", + "end": "fe80::ffff:ffff:ffff:ffff"}], + "gateway_ip": "fe80::f816:3eff:fe20:57c3", + "ip_version": 6, + "cidr": "fe80::/64", + "id": fake_neutron_subnet2_id, + "enable_dhcp": True + }] + } + return fake_subnet_response + + def _create_subnet_with_exception(self, neutron_network_id, + docker_endpoint_id, ex): + fake_neutron_subnet1_id = str(uuid.uuid4()) + fake_neutron_subnet2_id = str(uuid.uuid4()) + + self.mox.StubOutWithMock(app.neutron, 'create_subnet') + fake_subnet_request = { + 'subnets': [{ + 'name': '-'.join([docker_endpoint_id, '192.168.1.0']), + 'network_id': neutron_network_id, + 'allocation_pools': None, + 'ip_version': 4, + "cidr": '192.168.1.0/24' + }, { + 'name': '-'.join([docker_endpoint_id, 'fe80::']), + 'network_id': neutron_network_id, + 'allocation_pools': None, + 'ip_version': 6, + "cidr": 'fe80::/64' + }] + } + fake_subnets = self.__class__._get_fake_subnets( + docker_endpoint_id, neutron_network_id, + fake_neutron_subnet1_id, fake_neutron_subnet2_id) + + if ex: + app.neutron.create_subnet(fake_subnet_request).AndRaise(ex) + else: + app.neutron.create_subnet( + fake_subnet_request).AndReturn(fake_subnets) + self.mox.ReplayAll() + + return (fake_neutron_subnet1_id, fake_neutron_subnet2_id) + + def _delete_subnet_with_exception(self, neutron_subnet_id, ex): + self.mox.StubOutWithMock(app.neutron, 'delete_subnet') + if ex: + app.neutron.delete_subnet(neutron_subnet_id).AndRaise(ex) + else: + app.neutron.delete_subnet(neutron_subnet_id).AndReturn(None) + self.mox.ReplayAll() + + def _delete_subnets_with_exception(self, neutron_subnet_ids, ex): + self.mox.StubOutWithMock(app.neutron, 'delete_subnet') + for neutron_subnet_id in neutron_subnet_ids: + if ex: + app.neutron.delete_subnet(neutron_subnet_id).AndRaise(ex) + else: + app.neutron.delete_subnet(neutron_subnet_id).AndReturn(None) + self.mox.ReplayAll() + + def _create_port_with_exception(self, neutron_network_id, + docker_endpoint_id, neutron_subnetv4_id, + neutron_subnetv6_id, ex): + self.mox.StubOutWithMock(app.neutron, 'create_port') + fake_port_request = { + 'port': { + 'name': '-'.join([docker_endpoint_id, '0', 'port']), + 'admin_state_up': True, + 'fixed_ips': [{ + 'subnet_id': neutron_subnetv4_id, + 'ip_address': '192.168.1.2' + }, { + 'subnet_id': neutron_subnetv6_id, + 'ip_address': 'fe80::f816:3eff:fe20:57c4' + }], + 'mac_address': "fa:16:3e:20:57:c3", + 'network_id': neutron_network_id + } + } + # The following fake response is retrieved from the Neutron doc: + # http://developer.openstack.org/api-ref-networking-v2.html#createPort # noqa + fake_port = { + "port": { + "status": "DOWN", + "name": '-'.join([docker_endpoint_id, '0', 'port']), + "allowed_address_pairs": [], + "admin_state_up": True, + "network_id": neutron_network_id, + "tenant_id": "d6700c0c9ffa4f1cb322cd4a1f3906fa", + "device_owner": "", + "mac_address": "fa:16:3e:20:57:c3", + 'fixed_ips': [{ + 'subnet_id': neutron_subnetv4_id, + 'ip_address': '192.168.1.2' + }, { + 'subnet_id': neutron_subnetv6_id, + 'ip_address': 'fe80::f816:3eff:fe20:57c4' + }], + "id": "65c0ee9f-d634-4522-8954-51021b570b0d", + "security_groups": [], + "device_id": "" + } + } + if ex: + app.neutron.create_port(fake_port_request).AndRaise(ex) + else: + app.neutron.create_port(fake_port_request).AndReturn(fake_port) + self.mox.ReplayAll() + + def _invoke_create_request(self, docker_network_id, docker_endpoint_id): + data = { + 'NetworkID': docker_network_id, + 'EndpointID': docker_endpoint_id, + 'Options': {}, + 'Interfaces': [{ + 'ID': 0, + 'Address': '192.168.1.2/24', + 'AddressIPv6': 'fe80::f816:3eff:fe20:57c4/64', + 'MacAddress': "fa:16:3e:20:57:c3" + }] + } + response = self.app.post('/NetworkDriver.CreateEndpoint', + content_type='application/json', + data=jsonutils.dumps(data)) + return response + + @data(exceptions.Unauthorized, exceptions.Forbidden, exceptions.NotFound, + exceptions.Conflict) + def test_create_endpoint_subnet_failures(self, GivenException): + fake_docker_network_id = hashlib.sha256( + str(random.getrandbits(256))).hexdigest() + fake_docker_endpoint_id = hashlib.sha256( + str(random.getrandbits(256))).hexdigest() + fake_neutron_network_id = str(uuid.uuid4()) + + self.mox.StubOutWithMock(app.neutron, 'list_subnets') + app.neutron.list_subnets( + network_id=fake_neutron_network_id, + cidr='192.168.1.2/24').AndReturn({'subnets': []}) + app.neutron.list_subnets( + network_id=fake_neutron_network_id, + cidr='fe80::f816:3eff:fe20:57c4/64').AndReturn({'subnets': []}) + + self._create_subnet_with_exception( + fake_neutron_network_id, fake_docker_endpoint_id, GivenException()) + self._mock_out_network(fake_neutron_network_id, fake_docker_network_id) + + response = self._invoke_create_request( + fake_docker_network_id, fake_docker_endpoint_id) + + self.assertEqual(GivenException.status_code, response.status_code) + decoded_json = jsonutils.loads(response.data) + self.assertTrue('Err' in decoded_json) + self.assertEqual({'Err': GivenException.message}, decoded_json) + + @data(exceptions.Unauthorized, exceptions.Forbidden, exceptions.NotFound, + exceptions.ServiceUnavailable) + def test_create_endpoint_port_failures(self, GivenException): + fake_docker_network_id = hashlib.sha256( + str(random.getrandbits(256))).hexdigest() + fake_docker_endpoint_id = hashlib.sha256( + str(random.getrandbits(256))).hexdigest() + fake_neutron_network_id = str(uuid.uuid4()) + + self.mox.StubOutWithMock(app.neutron, 'list_subnets') + app.neutron.list_subnets( + network_id=fake_neutron_network_id, + cidr='192.168.1.2/24').AndReturn({'subnets': []}) + app.neutron.list_subnets( + network_id=fake_neutron_network_id, + cidr='fe80::f816:3eff:fe20:57c4/64').AndReturn({'subnets': []}) + + (fake_neutron_subnet1_id, + fake_neutron_subnet2_id) = self._create_subnet_with_exception( + fake_neutron_network_id, fake_docker_endpoint_id, None) + self._create_port_with_exception(fake_neutron_network_id, + fake_docker_endpoint_id, fake_neutron_subnet1_id, + fake_neutron_subnet2_id, GivenException()) + self._mock_out_network(fake_neutron_network_id, fake_docker_network_id) + + # The port creation is failed and Kuryr rolles the created subnet back. + self._delete_subnets_with_exception( + [fake_neutron_subnet1_id, fake_neutron_subnet2_id], None) + + response = self._invoke_create_request( + fake_docker_network_id, fake_docker_endpoint_id) + + self.assertEqual(GivenException.status_code, response.status_code) + decoded_json = jsonutils.loads(response.data) + self.assertTrue('Err' in decoded_json) + self.assertEqual({'Err': GivenException.message}, decoded_json) diff --git a/requirements.txt b/requirements.txt index d26de6b8..978fe1cb 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,5 +5,6 @@ pbr<2.0,>=1.3 Babel>=1.3 Flask>=0.10,<1.0 +netaddr>=0.7.12 oslo.serialization>=1.4.0 # Apache-2.0 python-neutronclient>=2.3.11,<3