From ea8eb2a59a321b29829c439021f0101071dfa14e Mon Sep 17 00:00:00 2001 From: Adit Sarfaty Date: Sun, 11 Dec 2016 10:14:29 +0200 Subject: [PATCH] IpPools support Adding support for IP pool create/delete/get actions, and also allocate & release IPs from the pool Change-Id: Ieac0aad2268cffa9d4fb5b521ebec268f2b408f3 --- vmware_nsxlib/tests/unit/v3/test_constants.py | 20 ++ vmware_nsxlib/tests/unit/v3/test_resources.py | 186 ++++++++++++++++++ vmware_nsxlib/v3/client.py | 17 +- vmware_nsxlib/v3/exceptions.py | 6 + vmware_nsxlib/v3/nsx_constants.py | 6 + vmware_nsxlib/v3/resources.py | 87 ++++++++ 6 files changed, 317 insertions(+), 5 deletions(-) diff --git a/vmware_nsxlib/tests/unit/v3/test_constants.py b/vmware_nsxlib/tests/unit/v3/test_constants.py index b51c8dfd..50495ffb 100644 --- a/vmware_nsxlib/tests/unit/v3/test_constants.py +++ b/vmware_nsxlib/tests/unit/v3/test_constants.py @@ -160,3 +160,23 @@ FAKE_QOS_PROFILE = { "_create_user": "admin", "_revision": 0 } + +FAKE_IP_POOL_UUID = uuidutils.generate_uuid() +FAKE_IP_POOL = { + "_revision": 0, + "id": FAKE_IP_POOL_UUID, + "display_name": "IPPool-IPV6-1", + "description": "IPPool-IPV6-1 Description", + "resource_type": "IpPool", + "subnets": [{ + "dns_nameservers": [ + "2002:a70:cbfa:1:1:1:1:1" + ], + "allocation_ranges": [{ + "start": "2002:a70:cbfa:0:0:0:0:1", + "end": "2002:a70:cbfa:0:0:0:0:5" + }], + "gateway_ip": "2002:a80:cbfa:0:0:0:0:255", + "cidr": "2002:a70:cbfa:0:0:0:0:0/24" + }], +} diff --git a/vmware_nsxlib/tests/unit/v3/test_resources.py b/vmware_nsxlib/tests/unit/v3/test_resources.py index 948ec937..accc5bce 100644 --- a/vmware_nsxlib/tests/unit/v3/test_resources.py +++ b/vmware_nsxlib/tests/unit/v3/test_resources.py @@ -540,3 +540,189 @@ class LogicalRouterPortTestCase(nsxlib_testcase.NsxClientTestCase): 'get', lrport, 'https://1.2.3.4/api/v1/logical-router-ports/?' 'logical_switch_id=%s' % switch_id) + + +class IpPoolTestCase(nsxlib_testcase.NsxClientTestCase): + + def _mocked_pool(self, session_response=None): + return self.mocked_resource( + resources.IpPool, session_response=session_response) + + def test_create_ip_pool_all_args(self): + """Test creating an IP pool + + returns the correct response and 201 status + """ + pool = self._mocked_pool() + + display_name = 'dummy' + gateway_ip = '1.1.1.1' + ranges = [{'start': '2.2.2.0', 'end': '2.2.2.255'}, + {'start': '3.2.2.0', 'end': '3.2.2.255'}] + cidr = '2.2.2.0/24' + description = 'desc' + dns_nameserver = '7.7.7.7' + pool.create(cidr, ranges=ranges, + display_name=display_name, + gateway_ip=gateway_ip, + description=description, + dns_nameservers=[dns_nameserver]) + + data = { + 'display_name': display_name, + 'description': description, + 'subnets': [{ + 'gateway_ip': gateway_ip, + 'allocation_ranges': ranges, + 'cidr': cidr, + 'dns_nameservers': [dns_nameserver] + }] + } + + test_client.assert_json_call( + 'post', pool, + 'https://1.2.3.4/api/v1/pools/ip-pools', + data=jsonutils.dumps(data, sort_keys=True)) + + def test_create_ip_pool_minimal_args(self): + pool = self._mocked_pool() + + ranges = [{'start': '2.2.2.0', 'end': '2.2.2.255'}, + {'start': '3.2.2.0', 'end': '3.2.2.255'}] + cidr = '2.2.2.0/24' + pool.create(cidr, ranges=ranges) + + data = { + 'subnets': [{ + 'allocation_ranges': ranges, + 'cidr': cidr, + }] + } + + test_client.assert_json_call( + 'post', pool, + 'https://1.2.3.4/api/v1/pools/ip-pools', + data=jsonutils.dumps(data, sort_keys=True)) + + def test_create_ip_pool_no_ranges_with_gateway(self): + pool = self._mocked_pool() + cidr = '2.2.2.0/30' + gateway_ip = '2.2.2.1' + pool.create(cidr, ranges=None, gateway_ip=gateway_ip) + exp_ranges = [{'start': '2.2.2.0', 'end': '2.2.2.0'}, + {'start': '2.2.2.2', 'end': '2.2.2.3'}] + + data = { + 'subnets': [{ + 'gateway_ip': gateway_ip, + 'allocation_ranges': exp_ranges, + 'cidr': cidr, + }] + } + + test_client.assert_json_call( + 'post', pool, + 'https://1.2.3.4/api/v1/pools/ip-pools', + data=jsonutils.dumps(data, sort_keys=True)) + + def test_create_ip_pool_no_ranges_no_gateway(self): + pool = self._mocked_pool() + cidr = '2.2.2.0/30' + pool.create(cidr, ranges=None) + exp_ranges = [{'start': '2.2.2.0', 'end': '2.2.2.3'}] + + data = { + 'subnets': [{ + 'allocation_ranges': exp_ranges, + 'cidr': cidr, + }] + } + + test_client.assert_json_call( + 'post', pool, + 'https://1.2.3.4/api/v1/pools/ip-pools', + data=jsonutils.dumps(data, sort_keys=True)) + + def test_create_ip_pool_no_cidr(self): + pool = self._mocked_pool() + gateway_ip = '1.1.1.1' + ranges = [{'start': '2.2.2.0', 'end': '2.2.2.255'}, + {'start': '3.2.2.0', 'end': '3.2.2.255'}] + cidr = None + + try: + pool.create(cidr, ranges=ranges, + gateway_ip=gateway_ip) + except exceptions.InvalidInput: + # This call should fail + pass + else: + self.fail("shouldn't happen") + + def test_get_ip_pool(self): + """Test getting a router port by router id""" + fake_ip_pool = test_constants.FAKE_IP_POOL.copy() + resp_resources = fake_ip_pool + + pool = self._mocked_pool( + session_response=mocks.MockRequestsResponse( + 200, jsonutils.dumps(resp_resources))) + + uuid = fake_ip_pool['id'] + result = pool.get(uuid) + self.assertEqual(fake_ip_pool, result) + test_client.assert_json_call( + 'get', pool, + 'https://1.2.3.4/api/v1/pools/ip-pools/%s' % uuid) + + def test_delete_ip_pool(self): + """Test deleting router port""" + pool = self._mocked_pool() + + uuid = test_constants.FAKE_IP_POOL['id'] + pool.delete(uuid) + test_client.assert_json_call( + 'delete', pool, + 'https://1.2.3.4/api/v1/pools/ip-pools/%s' % uuid) + + def test_allocate_ip_from_pool(self): + pool = self._mocked_pool() + + uuid = test_constants.FAKE_IP_POOL['id'] + addr = '1.1.1.1' + pool.allocate(uuid, ip_addr=addr) + + data = {'allocation_id': addr} + test_client.assert_json_call( + 'post', pool, + 'https://1.2.3.4/api/v1/pools/ip-pools/%s?action=ALLOCATE' % uuid, + data=jsonutils.dumps(data, sort_keys=True)) + + def test_release_ip_to_pool(self): + pool = self._mocked_pool() + + uuid = test_constants.FAKE_IP_POOL['id'] + addr = '1.1.1.1' + pool.release(uuid, addr) + + data = {'allocation_id': addr} + test_client.assert_json_call( + 'post', pool, + 'https://1.2.3.4/api/v1/pools/ip-pools/%s?action=RELEASE' % uuid, + data=jsonutils.dumps(data, sort_keys=True)) + + def test_get_ip_pool_allocations(self): + """Test getting a router port by router id""" + fake_ip_pool = test_constants.FAKE_IP_POOL.copy() + resp_resources = fake_ip_pool + + pool = self._mocked_pool( + session_response=mocks.MockRequestsResponse( + 200, jsonutils.dumps(resp_resources))) + + uuid = fake_ip_pool['id'] + result = pool.get_allocations(uuid) + self.assertEqual(fake_ip_pool, result) + test_client.assert_json_call( + 'get', pool, + 'https://1.2.3.4/api/v1/pools/ip-pools/%s/allocations' % uuid) diff --git a/vmware_nsxlib/v3/client.py b/vmware_nsxlib/v3/client.py index 75533972..41ce0372 100644 --- a/vmware_nsxlib/v3/client.py +++ b/vmware_nsxlib/v3/client.py @@ -94,9 +94,11 @@ class RESTClient(object): def url_post(self, url, body, headers=None): return self._rest_call(url, method='POST', body=body, headers=headers) - def _raise_error(self, status_code, operation, result_msg): + def _raise_error(self, status_code, operation, result_msg, + error_code=None): error = ERRORS.get(status_code, DEFAULT_ERROR) - raise error(manager='', operation=operation, details=result_msg) + raise error(manager='', operation=operation, details=result_msg, + error_code=error_code) def _validate_result(self, result, expected, operation): if result.status_code not in expected: @@ -109,14 +111,17 @@ class RESTClient(object): for code in expected]), 'body': result_msg}) + error_code = None if isinstance(result_msg, dict) and 'error_message' in result_msg: + error_code = result_msg.get('error_code') related_errors = [error['error_message'] for error in result_msg.get('related_errors', [])] result_msg = result_msg['error_message'] if related_errors: result_msg += " relatedErrors: %s" % ' '.join( related_errors) - self._raise_error(result.status_code, operation, result_msg) + self._raise_error(result.status_code, operation, result_msg, + error_code=error_code) @classmethod def merge_headers(cls, *headers): @@ -215,9 +220,11 @@ class NSX3Client(JSONRESTClient): default_headers=default_headers, client_obj=client_obj) - def _raise_error(self, status_code, operation, result_msg): + def _raise_error(self, status_code, operation, result_msg, + error_code=None): """Override the Rest client errors to add the manager IPs""" error = ERRORS.get(status_code, DEFAULT_ERROR) raise error(manager=self.nsx_api_managers, operation=operation, - details=result_msg) + details=result_msg, + error_code=error_code) diff --git a/vmware_nsxlib/v3/exceptions.py b/vmware_nsxlib/v3/exceptions.py index 81049fde..805f9473 100644 --- a/vmware_nsxlib/v3/exceptions.py +++ b/vmware_nsxlib/v3/exceptions.py @@ -63,6 +63,7 @@ class ManagerError(NsxLibException): self.msg = self.message % kwargs except KeyError: self.msg = details + self.error_code = kwargs.get('error_code') class ResourceNotFound(ManagerError): @@ -70,6 +71,11 @@ class ResourceNotFound(ManagerError): "%(operation)s") +class InvalidInput(ManagerError): + message = _("%(operation)s failed: Invalid input %(arg_val)s " + "for %(arg_name)s") + + class StaleRevision(ManagerError): pass diff --git a/vmware_nsxlib/v3/nsx_constants.py b/vmware_nsxlib/v3/nsx_constants.py index 5e45e404..5340b423 100644 --- a/vmware_nsxlib/v3/nsx_constants.py +++ b/vmware_nsxlib/v3/nsx_constants.py @@ -101,3 +101,9 @@ EGRESS = 'egress' INGRESS = 'ingress' EGRESS_SHAPING = 'EgressRateShaper' INGRESS_SHAPING = 'IngressRateShaper' + +# Error codes returned by the backend +ERR_CODE_OBJECT_NOT_FOUND = 202 +ERR_CODE_IPAM_POOL_EXHAUSTED = 5109 +ERR_CODE_IPAM_SPECIFIC_IP = 5123 +ERR_CODE_IPAM_IP_NOT_IN_POOL = 5110 diff --git a/vmware_nsxlib/v3/resources.py b/vmware_nsxlib/v3/resources.py index fbd03b27..e612d278 100644 --- a/vmware_nsxlib/v3/resources.py +++ b/vmware_nsxlib/v3/resources.py @@ -15,6 +15,7 @@ # import abc import collections +import netaddr import six from vmware_nsxlib._i18n import _ @@ -575,3 +576,89 @@ class LogicalDhcpServer(AbstractRESTResource): def delete_binding(self, server_uuid, binding_uuid): url = "%s/static-bindings/%s" % (server_uuid, binding_uuid) return self._client.url_delete(url) + + +class IpPool(AbstractRESTResource): + #TODO(asarfaty): Check the DK api - could be different + @property + def uri_segment(self): + return 'pools/ip-pools' + + def _generate_ranges(self, cidr, gateway_ip): + """Create list of ranges from the given cidr. + + Ignore the gateway_ip, if defined + """ + ip_set = netaddr.IPSet(netaddr.IPNetwork(cidr)) + if gateway_ip: + ip_set.remove(gateway_ip) + return [{"start": str(r[0]), + "end": str(r[-1])} for r in ip_set.iter_ipranges()] + + def create(self, cidr, ranges=None, display_name=None, description=None, + gateway_ip=None, dns_nameservers=None): + """Create an IpPool. + + Arguments: + cidr: (required) + ranges: (optional) a list of dictionaries, each with 'start' + and 'end' keys, and IP values. + If None: the cidr will be used to create the ranges, + excluding the gateway. + display_name: (optional) + description: (optional) + gateway_ip: (optional) + dns_nameservers: (optional) list of addresses + """ + if not cidr: + raise exceptions.InvalidInput(operation="IP Pool create", + arg_name="cidr", arg_val=cidr) + if not ranges: + # generate ranges from (cidr - gateway) + ranges = self._generate_ranges(cidr, gateway_ip) + + subnet = {"allocation_ranges": ranges, + "cidr": cidr} + if gateway_ip: + subnet["gateway_ip"] = gateway_ip + if dns_nameservers: + subnet["dns_nameservers"] = dns_nameservers + + body = {"subnets": [subnet]} + if description: + body['description'] = description + if display_name: + body['display_name'] = display_name + + return self._client.create(body=body) + + def delete(self, pool_id): + """Delete an IPPool by its ID.""" + return self._client.delete(pool_id) + + def update(self, uuid, *args, **kwargs): + # Not supported yet + pass + + def get(self, pool_id): + return self._client.get(pool_id) + + def allocate(self, pool_id, ip_addr=None): + """Allocate an IP from a pool.""" + # Note: Currently the backend does not support allocation of a + # specific IP, so an exception will be raised by the backend. + # Depending on the backend version, this may be allowed in the future + url = "%s?action=ALLOCATE" % pool_id + body = {"allocation_id": ip_addr} + return self._client.url_post(url, body=body) + + def release(self, pool_id, ip_addr): + """Release an IP back to a pool.""" + url = "%s?action=RELEASE" % pool_id + body = {"allocation_id": ip_addr} + return self._client.url_post(url, body=body) + + def get_allocations(self, pool_id): + """Return information about the allocated IPs in the pool.""" + url = "%s/allocations" % pool_id + return self._client.url_get(url)