Implement /NetworkDriver.CreateEndpoint

This patch replaces the mocked version of /NetworkDriver.CreateEndpoint
with the actual Neutron call. This unit test for the endpoint is also
implemented.

libnetwork's Endpoint is mapped into Neutron's subnets and port. The
request to create an Endpoint contains the information of libnetwork's
Interfaces to be managed, however in Neutron there's no single resource
corresponds to the Endpoint.

Therefore Kuryr breaks the Endpoint into the following three pieces.

1. Subnet for Address property in the request
   - Kuryr passes the given Address property to CIDR for the request
     against Neutron API as it is
2. Subnet for AddressIPv6 property in the request
   - Kuryr passes the given AddressIPv6 property to CIDR for the request
     against Neutron API as it is
3. Port which MAC address is the same as MacAddress property in the
   request

To distinguish Neutorn subnets created in 1. and 2., Kuryr names them by
the given EndpointId with the their subnet addresses as the postfixes.
For the Neutron port, Kuryr gives the EndpointID with the index of the
interface and the postfix, "port". For instance, if Address, i.e.,
10.0.1.1/24, and AddressIPV6, i.e., fe80::f816:3eff:fe20:57c4/64, which
corresponding Neutron subnets don't exist are given in a request against
/NetworkDriver.CreateEndpoint, the following subnets and a port will be
created.

* 98953db3f8e6628caf4a7cad3c866cb090654e3dee3e37206ad8c0a81355f1b7-10.0.1.0
* 98953db3f8e6628caf4a7cad3c866cb090654e3dee3e37206ad8c0a81355f1b7-fe80::
* 98953db3f8e6628caf4a7cad3c866cb090654e3dee3e37206ad8c0a81355f1b7-0-port

In the process 1. and 2., Kuryr doesn't specify the allocation list for
the addresses and it's the responsibility of Neutron to allocate an
appropriate set of the addresses.

In the latter workflow, where a container joins the network and be
assinged for the specific address, Kuryr binds the container with the
created port which IP address that is allocated in this phase.

This patch implements the following IPAM blueprint in Kuryr partially
except for retrieving the names of the default subnets from the config
file:

https://blueprints.launchpad.net/kuryr/+spec/ipam

Change-Id: I1798b3baff56e285059435f2b8620f36811b580f
Signed-off-by: Taku Fukushima <f.tac.mac@gmail.com>
This commit is contained in:
Taku Fukushima 2015-08-06 19:01:03 +02:00
parent feb62e91ef
commit daaa97fbda
4 changed files with 572 additions and 2 deletions

View File

@ -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'])

View File

@ -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)

View File

@ -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)

View File

@ -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