From 6fd81240b77074997c4ea0519716c5749f8b7601 Mon Sep 17 00:00:00 2001 From: Endre Karlson Date: Tue, 14 Jan 2014 12:38:20 +0100 Subject: [PATCH] FloatingIP PTR record functionality * Central methods update_floatingip, get_floatingip, list_floatingip * API according to * https://wiki.openstack.org/wiki/Designate/Blueprints/Reverse * Put network functionanlity into a pluggable api class (In case we want * more) blueprint floating-ip-ptrs Change-Id: Ic9194e5bcfc8d25126b9e84652144f58cddcccfe --- designate/api/v2/controllers/floatingips.py | 90 ++++++ designate/api/v2/controllers/reverse.py | 21 ++ designate/api/v2/controllers/root.py | 2 + designate/api/v2/views/floatingips.py | 36 +++ designate/central/__init__.py | 4 + designate/central/rpcapi.py | 16 +- designate/central/service.py | 297 +++++++++++++++++- designate/exceptions.py | 17 + designate/network_api/__init__.py | 115 +++++++ designate/network_api/fake.py | 80 +++++ designate/network_api/neutron.py | 148 +++++++++ .../resources/schemas/v2/floatingip.json | 54 ++++ .../resources/schemas/v2/floatingips.json | 38 +++ ...add_managed_tenant_and_region_and_extra.py | 61 ++++ designate/storage/impl_sqlalchemy/models.py | 3 + designate/tests/__init__.py | 28 ++ .../test_api/test_v2/test_floatingips.py | 250 +++++++++++++++ designate/tests/test_central/test_service.py | 265 ++++++++++++++++ designate/tests/test_network_api/__init__.py | 0 .../tests/test_network_api/test_neutron.py | 53 ++++ doc/source/index.rst | 1 + doc/source/integrations.rst | 53 ++++ etc/designate/designate.conf.sample | 27 ++ requirements.txt | 1 + setup.cfg | 4 + 25 files changed, 1662 insertions(+), 2 deletions(-) create mode 100644 designate/api/v2/controllers/floatingips.py create mode 100644 designate/api/v2/controllers/reverse.py create mode 100644 designate/api/v2/views/floatingips.py create mode 100644 designate/network_api/__init__.py create mode 100644 designate/network_api/fake.py create mode 100644 designate/network_api/neutron.py create mode 100644 designate/resources/schemas/v2/floatingip.json create mode 100644 designate/resources/schemas/v2/floatingips.json create mode 100644 designate/storage/impl_sqlalchemy/migrate_repo/versions/036_add_managed_tenant_and_region_and_extra.py create mode 100644 designate/tests/test_api/test_v2/test_floatingips.py create mode 100644 designate/tests/test_network_api/__init__.py create mode 100644 designate/tests/test_network_api/test_neutron.py create mode 100644 doc/source/integrations.rst diff --git a/designate/api/v2/controllers/floatingips.py b/designate/api/v2/controllers/floatingips.py new file mode 100644 index 000000000..162d5e9f8 --- /dev/null +++ b/designate/api/v2/controllers/floatingips.py @@ -0,0 +1,90 @@ +# Copyright 2013 Hewlett-Packard Development Company, L.P. +# +# Author: Endre Karlson +# +# 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 pecan +import re +from designate import exceptions +from designate import schema +from designate.api.v2.controllers import rest +from designate.api.v2.views import floatingips as floatingips_views +from designate.central import rpcapi as central_rpcapi + + +central_api = central_rpcapi.CentralAPI() + + +FIP_REGEX = '^(?P[A-Za-z0-9\\.\\-_]{1,100}):' \ + '(?P[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-' \ + '[0-9a-fA-F]{4}-[0-9a-fA-F]{12})$' + + +def fip_key_to_data(key): + m = re.match(FIP_REGEX, key) + + # NOTE: Ensure that the fip matches region:floatingip_id or raise, if + # not this will cause a 500. + if m is None: + msg = 'Floating IP %s is not in the format of :' + raise exceptions.BadRequest(msg % key) + return m.groups() + + +class FloatingIPController(rest.RestController): + _view = floatingips_views.FloatingIPView() + _resource_schema = schema.Schema('v2', 'floatingip') + _collection_schema = schema.Schema('v2', 'floatingips') + + @pecan.expose(template='json:', content_type='application/json') + def get_all(self, **params): + """ List Floating IP PTRs for a Tenant """ + request = pecan.request + context = request.environ['context'] + + fips = central_api.list_floatingips(context) + return self._view.list(context, request, fips) + + @pecan.expose(template='json:', content_type='application/json') + def patch_one(self, fip_key): + """ + Set or unset a PTR + """ + request = pecan.request + context = request.environ['context'] + body = request.body_dict + + region, id_ = fip_key_to_data(fip_key) + + # Validate the request conforms to the schema + self._resource_schema.validate(body) + + fip = central_api.update_floatingip( + context, region, id_, body['floatingip']) + + if fip: + return self._view.basic(context, request, fip) + + @pecan.expose(template='json:', content_type='application/json') + def get_one(self, fip_key): + """ + Get PTR + """ + request = pecan.request + context = request.environ['context'] + + region, id_ = fip_key_to_data(fip_key) + + fip = central_api.get_floatingip(context, region, id_) + + return self._view.basic(context, request, fip) diff --git a/designate/api/v2/controllers/reverse.py b/designate/api/v2/controllers/reverse.py new file mode 100644 index 000000000..48f8576e8 --- /dev/null +++ b/designate/api/v2/controllers/reverse.py @@ -0,0 +1,21 @@ +# Copyright 2013 Hewlett-Packard Development Company, L.P. +# +# Author: Endre Karlson +# +# 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. +from designate.api.v2.controllers import rest +from designate.api.v2.controllers import floatingips + + +class ReverseController(rest.RestController): + floatingips = floatingips.FloatingIPController() diff --git a/designate/api/v2/controllers/root.py b/designate/api/v2/controllers/root.py index 03be5da56..4120b2493 100644 --- a/designate/api/v2/controllers/root.py +++ b/designate/api/v2/controllers/root.py @@ -15,6 +15,7 @@ # under the License. from designate.openstack.common import log as logging from designate.api.v2.controllers import limits +from designate.api.v2.controllers import reverse from designate.api.v2.controllers import schemas from designate.api.v2.controllers import zones @@ -28,4 +29,5 @@ class RootController(object): """ limits = limits.LimitsController() schemas = schemas.SchemasController() + reverse = reverse.ReverseController() zones = zones.ZonesController() diff --git a/designate/api/v2/views/floatingips.py b/designate/api/v2/views/floatingips.py new file mode 100644 index 000000000..2d250a443 --- /dev/null +++ b/designate/api/v2/views/floatingips.py @@ -0,0 +1,36 @@ +# Copyright 2013 Hewlett-Packard Development Company, L.P. +# +# Author: Endre Karlson +# +# 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. +from designate.api.v2.views import base as base_view +from designate.openstack.common import log as logging + + +LOG = logging.getLogger(__name__) + + +class FloatingIPView(base_view.BaseView): + """ Model a FloatingIP PTR record as a python dict """ + _resource_name = 'floatingip' + _collection_name = 'floatingips' + + def _get_base_href(self, parents=None): + return '%s/reverse/floatingips' % self.base_uri + + def basic(self, context, request, data): + data['id'] = ":".join([data.pop('region'), data.pop('id')]) + data['links'] = self._get_resource_links( + request, data, [data['id']]) + return { + 'floatingip': data} diff --git a/designate/central/__init__.py b/designate/central/__init__.py index 02b3b7a6e..59722ed0f 100644 --- a/designate/central/__init__.py +++ b/designate/central/__init__.py @@ -41,4 +41,8 @@ cfg.CONF.register_opts([ cfg.IntOpt('max_recordset_name_len', default=255, help="Maximum recordset name length", deprecated_name='max_record_name_len'), + cfg.StrOpt('managed_resource_email', default='email@example.io', + help='E-Mail for Managed resources'), + cfg.StrOpt('managed_resource_tenant_id', + help="The Tenant ID that will own any managed resources.") ], group='service:central') diff --git a/designate/central/rpcapi.py b/designate/central/rpcapi.py index c6a582209..7f052fee7 100644 --- a/designate/central/rpcapi.py +++ b/designate/central/rpcapi.py @@ -33,7 +33,7 @@ class CentralAPI(rpc_proxy.RpcProxy): 2.0 - Renamed most get_resources to find_resources 2.1 - Add quota methods 3.0 - RecordSet Changes - + 3.1 - Add floating ip ptr methods """ def __init__(self, topic=None): topic = topic if topic else cfg.CONF.central_topic @@ -353,3 +353,17 @@ class CentralAPI(rpc_proxy.RpcProxy): record_id=record_id) return self.call(context, msg) + + def list_floatingips(self, context): + msg = self.make_msg('list_floatingips') + return self.call(context, msg, version="3.1") + + def get_floatingip(self, context, region, floatingip_id): + msg = self.make_msg('get_floatingip', region=region, + floatingip_id=floatingip_id) + return self.call(context, msg, version="3.1") + + def update_floatingip(self, context, region, floatingip_id, values): + msg = self.make_msg('update_floatingip', region=region, + floatingip_id=floatingip_id, values=values) + return self.call(context, msg) diff --git a/designate/central/service.py b/designate/central/service.py index 968d30497..8033a1833 100644 --- a/designate/central/service.py +++ b/designate/central/service.py @@ -27,6 +27,7 @@ from designate import policy from designate import quota from designate import utils from designate.storage import api as storage_api +from designate import network_api LOG = logging.getLogger(__name__) @@ -45,7 +46,7 @@ def wrap_backend_call(): class Service(rpc_service.Service): - RPC_API_VERSION = '3.0' + RPC_API_VERSION = '3.1' def __init__(self, *args, **kwargs): backend_driver = cfg.CONF['service:central'].backend_driver @@ -69,6 +70,8 @@ class Service(rpc_service.Service): self.quota = quota.get_quota() self.effective_tld = effectivetld.EffectiveTld() + self.network_api = network_api.get_api(cfg.CONF.network_api) + def start(self): self.backend.start() @@ -964,3 +967,295 @@ class Service(rpc_service.Service): 'backend': backend_status, 'storage': storage_status } + + def _determine_floatingips(self, context, fips, records=None, + tenant_id=None): + """ + Given the context or tenant, records and fips it returns the valid + floatingips either with a associated record or not. Deletes invalid + records also. + + Returns a list of tuples with FloatingIPs and it's Record. + """ + tenant_id = tenant_id or context.tenant_id + + elevated_context = context.elevated() + elevated_context.all_tenants = True + + criterion = { + 'managed': True, + 'managed_resource_type': 'ptr:floatingip', + } + + records = self.find_records(elevated_context, criterion) + records = dict([(r['managed_extra'], r) for r in records]) + + invalid = [] + data = {} + # First populate the list of FIPS + for fip_key, fip_values in fips.items(): + # Check if the FIP has a record + record = records.get(fip_values['address']) + + # NOTE: Now check if it's owned by the tenant that actually has the + # FIP in the external service and if not invalidate it (delete it) + # thus not returning it with in the tuple with the FIP, but None.. + + if record: + record_tenant = record['managed_tenant_id'] + + if record_tenant != tenant_id: + msg = "Invalid FloatingIP %s belongs to %s but record " \ + "owner %s" + LOG.debug(msg, fip_key, tenant_id, record_tenant) + + invalid.append(record) + record = None + data[fip_key] = (fip_values, record) + + return data, invalid + + def _invalidate_floatingips(self, context, records): + """ + Utility method to delete a list of records. + """ + elevated_context = context.elevated() + elevated_context.all_tenants = True + + if records > 0: + for r in records: + msg = 'Deleting record %s for FIP %s' + LOG.debug(msg, r['id'], r['managed_resource_id']) + self.delete_record(elevated_context, r['domain_id'], + r['recordset_id'], r['id']) + + def _format_floatingips(self, context, data, recordsets=None): + """ + Given a list of FloatingIP and Record tuples we look through creating + a new dict of FloatingIPs + """ + elevated_context = context.elevated() + elevated_context.all_tenants = True + + fips = {} + for key, value in data.items(): + fip_ptr = { + 'address': value[0]['address'], + 'id': value[0]['id'], + 'region': value[0]['region'], + 'ptrdname': None, + 'ttl': None, + 'description': None + } + + # TTL population requires a present record in order to find the + # RS or Zone + if value[1]: + # We can have a recordset dict passed in + if (recordsets is not None and + value[1]['recordset_id'] in recordsets): + recordset = recordsets[value[1]['recordset_id']] + else: + recordset = self.storage_api.get_recordset( + elevated_context, value[1]['recordset_id']) + + if recordset['ttl'] is not None: + fip_ptr['ttl'] = recordset['ttl'] + else: + zone = self.get_domain( + elevated_context, value[1]['domain_id']) + fip_ptr['ttl'] = zone['ttl'] + + fip_ptr['ptrdname'] = value[1]['data'] + else: + LOG.debug("No record information found for %s", + value[0]['id']) + + # Store the "fip_record" with the region and it's id as key + fips[key] = fip_ptr + return fips + + def _list_floatingips(self, context, region=None): + data = self.network_api.list_floatingips(context, region=region) + return self._list_to_dict(data, keys=['region', 'id']) + + def _list_to_dict(self, data, keys=['id']): + new = {} + for i in data: + key = tuple([i[key] for key in keys]) + new[key] = i + return new + + def _get_floatingip(self, context, region, floatingip_id, fips): + if (region, floatingip_id) not in fips: + msg = 'FloatingIP %s in %s is not associated for tenant "%s"' % \ + (floatingip_id, region, context.tenant_id) + raise exceptions.NotFound(msg) + return fips[region, floatingip_id] + + # PTR ops + def list_floatingips(self, context): + """ + List Floating IPs PTR + + A) We have service_catalog in the context and do a lookup using the + token pr Neutron in the SC + B) We lookup FIPs using the configured values for this deployment. + """ + elevated_context = context.elevated() + elevated_context.all_tenants = True + + tenant_fips = self._list_floatingips(context) + + valid, invalid = self._determine_floatingips( + elevated_context, tenant_fips) + + self._invalidate_floatingips(context, invalid) + + return self._format_floatingips(context, valid).values() + + def get_floatingip(self, context, region, floatingip_id): + """ + Get Floating IP PTR + """ + elevated_context = context.elevated() + elevated_context.all_tenants = True + + tenant_fips = self._list_floatingips(context, region=region) + + self._get_floatingip(context, region, floatingip_id, tenant_fips) + + valid, invalid = self._determine_floatingips( + elevated_context, tenant_fips) + + self._invalidate_floatingips(context, invalid) + + mangled = self._format_floatingips(context, valid) + return mangled[region, floatingip_id] + + def _set_floatingip_reverse(self, context, region, floatingip_id, values): + """ + Set the FloatingIP's PTR record based on values. + """ + values.setdefault('description', None) + + elevated_context = context.elevated() + elevated_context.all_tenants = True + + tenant_fips = self._list_floatingips(context, region=region) + + fip = self._get_floatingip(context, region, floatingip_id, tenant_fips) + + zone_name = self.network_api.address_zone(fip['address']) + + # NOTE: Find existing zone or create it.. + try: + zone = self.storage_api.find_domain( + elevated_context, {'name': zone_name}) + except exceptions.DomainNotFound: + msg = 'Creating zone for %s:%s - %s zone %s' % \ + (floatingip_id, region, fip['address'], zone_name) + LOG.info(msg) + + email = cfg.CONF['service:central'].managed_resource_email + tenant_id = cfg.CONF['service:central'].managed_resource_tenant_id + + zone_values = { + 'name': zone_name, + 'email': email, + 'tenant_id': tenant_id + } + + zone = self.create_domain(elevated_context, zone_values) + + record_name = self.network_api.address_name(fip['address']) + + try: + # NOTE: Delete the current recormdset if any (also purges records) + LOG.debug("Removing old RRset / Record") + rset = self.find_recordset( + elevated_context, {'name': record_name, 'type': 'PTR'}) + + records = self.find_records( + elevated_context, {'recordset_id': rset['id']}) + + for record in records: + self.delete_record( + elevated_context, + rset['domain_id'], + rset['id'], + record['id']) + self.delete_recordset(elevated_context, zone['id'], rset['id']) + except exceptions.RecordSetNotFound: + pass + + recordset_values = { + 'name': record_name, + 'type': 'PTR', + 'ttl': values.get('ttl', None) + } + + recordset = self.create_recordset( + elevated_context, zone['id'], recordset_values) + + record_values = { + 'data': values['ptrdname'], + 'description': values['description'], + 'type': 'PTR', + 'managed': True, + 'managed_extra': fip['address'], + 'managed_resource_id': floatingip_id, + 'managed_resource_region': region, + 'managed_resource_type': 'ptr:floatingip', + 'managed_tenant_id': context.tenant_id + } + + record = self.create_record( + elevated_context, zone['id'], recordset['id'], record_values) + + mangled = self._format_floatingips( + context, {(region, floatingip_id): (fip, record)}, + {recordset['id']: recordset}) + + return mangled[region, floatingip_id] + + def _unset_floatingip_reverse(self, context, region, floatingip_id): + """ + Unset the FloatingIP PTR record based on the + + Service's FloatingIP ID > managed_resource_id + Tenant ID > managed_tenant_id + + We find the record based on the criteria and delete it or raise. + """ + elevated_context = context.elevated() + elevated_context.all_tenants = True + + criterion = { + 'managed_resource_id': floatingip_id, + 'managed_tenant_id': context.tenant_id + } + + try: + record = self.storage_api.find_record( + elevated_context, criterion=criterion) + except exceptions.RecordNotFound: + msg = 'No such FloatingIP %s:%s' % (region, floatingip_id) + raise exceptions.NotFound(msg) + + self.delete_record( + elevated_context, + record['domain_id'], + record['recordset_id'], + record['id']) + + def update_floatingip(self, context, region, floatingip_id, values): + """ + We strictly see if values['ptrdname'] is str or None and set / unset + the requested FloatingIP's PTR record based on that. + """ + if values['ptrdname'] is None: + self._unset_floatingip_reverse(context, region, floatingip_id) + elif isinstance(values['ptrdname'], basestring): + return self._set_floatingip_reverse( + context, region, floatingip_id, values) diff --git a/designate/exceptions.py b/designate/exceptions.py index 63fde1d2b..675989340 100644 --- a/designate/exceptions.py +++ b/designate/exceptions.py @@ -46,6 +46,18 @@ class ConfigurationError(Base): error_type = 'configuration_error' +class CommunicationFailure(Base): + error_code = 504 + error_type = 'communication_failure' + + +class NeutronCommunicationFailure(CommunicationFailure): + """ + Raised in case one of the alledged Neutron endpoints fails. + """ + error_type = 'neutron_communication_failure' + + class NoServersConfigured(ConfigurationError): error_code = 500 error_type = 'no_servers_configured' @@ -70,6 +82,11 @@ class BadRequest(Base): error_type = 'bad_request' +class NetworkEndpointNotFound(BadRequest): + error_type = 'no_endpoint' + error_code = 403 + + class InvalidOperation(BadRequest): error_code = 400 error_type = 'invalid_operation' diff --git a/designate/network_api/__init__.py b/designate/network_api/__init__.py new file mode 100644 index 000000000..547489b22 --- /dev/null +++ b/designate/network_api/__init__.py @@ -0,0 +1,115 @@ +# Copyright 2013 Hewlett-Packard Development Company, L.P. +# +# Author: Endre Karlson +# +# 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. +from dns import reversename +from oslo.config import cfg +from stevedore import driver + +from designate import exceptions +from designate.openstack.common import log as logging + + +cfg.CONF.register_opts([ + cfg.StrOpt('network_api', default='neutron', help='Which API to use.') +]) + + +LOG = logging.getLogger() + + +def get_api(api): + mngr = driver.DriverManager('designate.network_api', api) + return mngr.driver() + + +class BaseAPI(object): + """ + Base API + """ + def _endpoints(self, service_catalog=None, service_type=None, + endpoint_type='publicURL', config_section=None, + region=None): + if service_catalog is not None: + endpoints = self._endpoints_from_catalog( + service_catalog, service_type, endpoint_type, + region=region) + elif config_section is not None: + endpoints = [] + for u in cfg.CONF[config_section].endpoints: + e_region, e = u.split('|') + # Filter if region is given + if (e_region and region) and e_region != region: + continue + endpoints.append((e, e_region)) + + if not endpoints: + msg = 'Endpoints are not configured' + raise exceptions.ConfigurationError(msg) + else: + msg = 'No service_catalog and no configured endpoints' + raise exceptions.ConfigurationError(msg) + + LOG.debug('Returning endpoints: %s' % endpoints) + return endpoints + + def _endpoints_from_catalog(self, service_catalog, service_type, + endpoint_type, region=None): + """ + Return the endpoints for the given service from the context's sc + or lookup towards the configured keystone. + + return [('http://endpoint', 'region')] + """ + urls = [] + for svc in service_catalog: + if svc['type'] != service_type: + continue + for url in svc['endpoints']: + if endpoint_type in url: + if region is not None and url['region'] != region: + continue + urls.append((url[endpoint_type], url['region'])) + if not urls: + raise exceptions.NetworkEndpointNotFound + return urls + + def list_floatingips(self, context, region=None): + """ + List Floating IPs. + + Should return something like: + + [{ + 'address': ', + 'region': '', + 'id': '' + }] + """ + raise NotImplementedError + + @staticmethod + def address_zone(address): + """ + Get the zone a address belongs to. + """ + parts = reversed(address.split('.')[:-1]) + return '%s.in-addr.arpa.' % ".".join(parts) + + @staticmethod + def address_name(address): + """ + Get the name for the address + """ + return reversename.from_address(address).to_text() diff --git a/designate/network_api/fake.py b/designate/network_api/fake.py new file mode 100644 index 000000000..704a3dfc8 --- /dev/null +++ b/designate/network_api/fake.py @@ -0,0 +1,80 @@ +# Copyright 2013 Hewlett-Packard Development Company, L.P. +# +# Author: Endre Karlson +# +# 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 uuid +from designate.openstack.common import log +from designate.network_api import BaseAPI + + +LOG = log.getLogger(__name__) + +POOL = dict([(str(uuid.uuid4()), '192.168.2.%s' % i) for i in xrange(0, 254)]) +ALLOCATIONS = {} + + +def _format_floatingip(id_, address): + return { + 'region': 'RegionOne', + 'address': address, + 'id': id_ + } + + +def allocate_floatingip(tenant_id, floatingip_id=None): + """ + Allocates a floating ip from the pool to the tenant. + """ + ALLOCATIONS.setdefault(tenant_id, {}) + + id_ = floatingip_id or POOL.keys()[0] + + ALLOCATIONS[tenant_id][id_] = POOL.pop(id_) + values = _format_floatingip(id_, ALLOCATIONS[tenant_id][id_]) + LOG.debug("Allocated to id_ %s to %s - %s", id_, tenant_id, values) + return values + + +def deallocate_floatingip(id_): + """ + Deallocate a floatingip + """ + LOG.debug('De-allocating %s' % id_) + for tenant_id, allocated in ALLOCATIONS.items(): + if id_ in allocated: + POOL[id_] = allocated.pop(id_) + break + else: + raise KeyError('No such FloatingIP %s' % id_) + + +def reset_floatingips(): + LOG.debug('Resetting any allocations.') + for tenant_id, allocated in ALLOCATIONS.items(): + for key, value in allocated.items(): + POOL[key] = allocated.pop(key) + + +class API(BaseAPI): + def list_floatingips(self, context, region=None): + if context.is_admin: + data = [] + for tenant_id, allocated in ALLOCATIONS.items(): + data.extend(allocated.items()) + else: + data = ALLOCATIONS.get(context.tenant_id, {}).items() + + formatted = [_format_floatingip(k, v) for k, v in data] + LOG.debug('Returning %i FloatingIPs: %s', len(formatted), formatted) + return formatted diff --git a/designate/network_api/neutron.py b/designate/network_api/neutron.py new file mode 100644 index 000000000..b4d2d5792 --- /dev/null +++ b/designate/network_api/neutron.py @@ -0,0 +1,148 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 +# +# Copyright 2012 OpenStack Foundation +# All Rights Reserved +# +# 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. +# +# Copied partially from nova + +from neutronclient.v2_0 import client as clientv20 +from neutronclient.common import exceptions as neutron_exceptions +from oslo.config import cfg + +from designate import exceptions + +from designate.openstack.common import log as logging +from designate.openstack.common import threadgroup +from designate.network_api import BaseAPI + + +CONF = cfg.CONF +LOG = logging.getLogger(__name__) + + +neutron_opts = [ + cfg.ListOpt('endpoints', + help='URL to use if None in the ServiceCatalog that is ' + 'passed by the requrest context. Format: |'), + cfg.StrOpt('endpoint_type', default='publicURL', + help="Endpoint type to use"), + cfg.IntOpt('timeout', + default=30, + help='timeout value for connecting to neutron in seconds'), + cfg.StrOpt('admin_username', + help='username for connecting to neutron in admin context'), + cfg.StrOpt('admin_password', + help='password for connecting to neutron in admin context', + secret=True), + cfg.StrOpt('admin_tenant_name', + help='tenant name for connecting to neutron in admin context'), + cfg.StrOpt('auth_url', + help='auth url for connecting to neutron in admin context'), + cfg.BoolOpt('insecure', + default=False, + help='if set, ignore any SSL validation issues'), + cfg.StrOpt('auth_strategy', + default='keystone', + help='auth strategy for connecting to ' + 'neutron in admin context'), + cfg.StrOpt('ca_certificates_file', + help='Location of ca certificates file to use for ' + 'neutron client requests.'), +] + +cfg.CONF.register_opts(neutron_opts, group='network_api:neutron') + + +def get_client(context, endpoint): + params = { + 'endpoint_url': endpoint, + 'timeout': CONF['network_api:neutron'].timeout, + 'insecure': CONF['network_api:neutron'].insecure, + 'ca_cert': CONF['network_api:neutron'].ca_certificates_file, + } + + if context.auth_token: + params['token'] = context.auth_token + params['auth_strategy'] = None + elif CONF['network_api:neutron'].admin_username is not None: + params['username'] = CONF['network_api:neutron'].admin_username + params['tenant_name'] = CONF['network_api:neutron'].admin_tenant_name + params['password'] = CONF['network_api:neutron'].admin_password + params['auth_url'] = CONF['network_api:neutron'].admin_auth_url + params['auth_strategy'] = CONF['network_api:neutron'].auth_strategy + return clientv20.Client(**params) + + +class API(BaseAPI): + """ + Interact with the Neutron API + """ + def list_floatingips(self, context, region=None): + """ + Get floating ips based on the current context from Neutron + """ + endpoints = self._endpoints( + service_catalog=context.service_catalog, + service_type='network', + endpoint_type=CONF['network_api:neutron'].endpoint_type, + config_section='network_api:neutron', + region=region) + + tg = threadgroup.ThreadGroup() + + failed = [] + data = [] + + def _call(endpoint, region, *args, **kw): + client = get_client(context, endpoint=endpoint) + LOG.debug("Attempting to fetch FloatingIPs from %s @ %s", + endpoint, region) + try: + fips = client.list_floatingips(*args, **kw) + except neutron_exceptions.Unauthorized as e: + # NOTE: 401 might be that the user doesn't have neutron + # activated in a particular region, we'll just log the failure + # and go on with our lives. + msg = "Calling Neutron resulted in a 401, please investigate." + LOG.warning(msg) + LOG.exception(e) + return + except Exception as e: + LOG.error('Failed calling Neutron %s - %s', region, endpoint) + LOG.exception(e) + failed.append((e, endpoint, region)) + return + + for fip in fips['floatingips']: + data.append({ + 'id': fip['id'], + 'address': fip['floating_ip_address'], + 'region': region + }) + + LOG.debug("Added %i FloatingIPs from %s @ %s", len(data), + endpoint, region) + + for endpoint, region in endpoints: + tg.add_thread(_call, endpoint, region, + tenant_id=context.tenant_id) + tg.wait() + + # NOTE: Sadly tg code doesn't give us a good way to handle failures. + if failed: + msg = 'Failed retrieving FLoatingIPs from Neutron in %s' % \ + ", ".join(['%s - %s' % (i[1], i[2]) for i in failed]) + raise exceptions.NeutronCommunicationFailure(msg) + return data diff --git a/designate/resources/schemas/v2/floatingip.json b/designate/resources/schemas/v2/floatingip.json new file mode 100644 index 000000000..193dfdd6d --- /dev/null +++ b/designate/resources/schemas/v2/floatingip.json @@ -0,0 +1,54 @@ +{ + "$schema": "http://json-schema.org/draft-04/hyper-schema", + + "id": "floatingip", + + "title": "floatingip", + "description": "Floating IP PTR", + "additionalProperties": false, + + "required": ["floatingip"], + + "properties": { + "floatingip": { + "type": "object", + "additionalProperties": false, + + "properties": { + "id": { + "type": "string", + "description": "Floating IP PTR identifier", + "pattern": "^[A-Za-z0-9\\.\\-_]{1,100}:([0-9a-fA-F]){8}-([0-9a-fA-F]){4}-([0-9a-fA-F]){4}-([0-9a-fA-F]){4}-([0-9a-fA-F]){12}$", + "readOnly": true + }, + "ptrdname": { + "type": ["string", "null"], + "format": "hostname", + "required:": true + }, + "description": { + "type": ["string", "null"], + "description": "Description for the PTR", + "maxLength": 160 + }, + "ttl": { + "type": "integer", + "description": "Default time to live", + "min": 0, + "max": 2147483647 + }, + "links": { + "type": "object", + "additionalProperties": false, + + "properties": { + "self": { + "type": "string", + "format": "url" + } + } + } + } + } + } +} diff --git a/designate/resources/schemas/v2/floatingips.json b/designate/resources/schemas/v2/floatingips.json new file mode 100644 index 000000000..d9286b1c6 --- /dev/null +++ b/designate/resources/schemas/v2/floatingips.json @@ -0,0 +1,38 @@ +{ + "$schema": "http://json-schema.org/draft-04/hyper-schema", + + "id": "floatingips", + + "title": "floatingips", + "description": "Floating IP PTRs", + "additionalProperties": false, + + "required": ["floatingips"], + + "properties": { + "recordsets": { + "type": "array", + "description": "Floating IP", + "items": {"$ref": "floatingips#/properties/flaotingip"} + }, + "links": { + "type": "object", + "additionalProperties": false, + + "properties": { + "self": { + "type": "string", + "format": "url" + }, + "next": { + "type": ["string", "null"], + "format": "url" + }, + "previous": { + "type": ["string", "null"], + "format": "url" + } + } + } + } +} diff --git a/designate/storage/impl_sqlalchemy/migrate_repo/versions/036_add_managed_tenant_and_region_and_extra.py b/designate/storage/impl_sqlalchemy/migrate_repo/versions/036_add_managed_tenant_and_region_and_extra.py new file mode 100644 index 000000000..9ffa361d6 --- /dev/null +++ b/designate/storage/impl_sqlalchemy/migrate_repo/versions/036_add_managed_tenant_and_region_and_extra.py @@ -0,0 +1,61 @@ +# Copyright 2013 Hewlett-Packard Development Company, L.P. +# +# Author: Endre Karlson +# +# 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. +# +# This is a placeholder for Havana backports. +# Do not use this number for new Icehouse work. New Icehouse work starts after +# all the placeholders. +# +# See https://blueprints.launchpad.net/nova/+spec/backportable-db-migrations +# http://lists.openstack.org/pipermail/openstack-dev/2013-March/006827.html +from designate.openstack.common import log as logging +from sqlalchemy import MetaData, Table, Column, Unicode + + +LOG = logging.getLogger(__name__) +meta = MetaData() + + +def upgrade(migrate_engine): + meta.bind = migrate_engine + records_table = Table('records', meta, autoload=True) + record_managed_tenant_id = Column( + 'managed_tenant_id', Unicode(36), default=None, nullable=True) + record_managed_tenant_id.create(records_table, populate_default=True) + + record_managed_resource_region = Column( + 'managed_resource_region', Unicode(100), default=None, nullable=True) + record_managed_resource_region.create(records_table, populate_default=True) + + record_managed_extra = Column( + 'managed_extra', Unicode(100), default=None, nullable=True) + record_managed_extra.create(records_table, populate_default=True) + + +def downgrade(migrate_engine): + meta.bind = migrate_engine + records_table = Table('records', meta, autoload=True) + + record_managed_tenant_id = Column( + 'managed_tenant_id', Unicode(36), default=None, nullable=True) + record_managed_tenant_id.drop(records_table) + + record_managed_resource_region = Column( + 'managed_resource_region', Unicode(100), default=None, nullable=True) + record_managed_resource_region.drop(records_table) + + record_extra = Column( + 'managed_extra', Unicode(100), default=None, nullable=True) + record_extra.drop(records_table) diff --git a/designate/storage/impl_sqlalchemy/models.py b/designate/storage/impl_sqlalchemy/models.py index 4118dbf81..0c643df72 100644 --- a/designate/storage/impl_sqlalchemy/models.py +++ b/designate/storage/impl_sqlalchemy/models.py @@ -143,10 +143,13 @@ class Record(Base): hash = Column(String(32), nullable=False, unique=True) managed = Column(Boolean, default=False) + managed_extra = Column(Unicode(100), default=None, nullable=True) managed_plugin_type = Column(Unicode(50), default=None, nullable=True) managed_plugin_name = Column(Unicode(50), default=None, nullable=True) managed_resource_type = Column(Unicode(50), default=None, nullable=True) + managed_resource_region = Column(Unicode(100), default=None, nullable=True) managed_resource_id = Column(UUID, default=None, nullable=True) + managed_tenant_id = Column(Unicode(36), default=None, nullable=True) status = Column(Enum(name='resource_statuses', *RESOURCE_STATUSES), nullable=False, server_default='ACTIVE', default='ACTIVE') diff --git a/designate/tests/__init__.py b/designate/tests/__init__.py index b07efdc03..315ab2a11 100644 --- a/designate/tests/__init__.py +++ b/designate/tests/__init__.py @@ -34,6 +34,8 @@ from designate.openstack.common import uuidutils from designate.context import DesignateContext from designate.tests import resources from designate import exceptions +from designate.network_api import fake as fake_network_api +from designate import network_api LOG = logging.getLogger(__name__) @@ -117,6 +119,14 @@ class DatabaseFixture(fixtures.Fixture): shutil.copyfile(self.golden_db, self.working_copy) +class NetworkAPIFixture(fixtures.Fixture): + def setUp(self): + super(NetworkAPIFixture, self).setUp() + self.api = network_api.get_api(cfg.CONF.network_api) + self.fake = fake_network_api + self.addCleanup(self.fake.reset_floatingips) + + class TestCase(test.BaseTestCase): quota_fixtures = [{ 'resource': 'domains', @@ -184,6 +194,11 @@ class TestCase(test.BaseTestCase): ] } + ptr_fixtures = [ + {'ptrdname': 'srv1.example.com.'}, + {'ptrdname': 'srv1.example.net.'} + ] + def setUp(self): super(TestCase, self).setUp() @@ -226,6 +241,11 @@ class TestCase(test.BaseTestCase): group='storage:sqlalchemy' ) + self.config(network_api='fake') + self.config( + managed_resource_tenant_id='managing_tenant', + group='service:central') + self.CONF([], project='designate') self.notifications = NotifierFixture() @@ -233,6 +253,9 @@ class TestCase(test.BaseTestCase): self.useFixture(PolicyFixture()) + self.network_api = NetworkAPIFixture() + self.useFixture(self.network_api) + self.admin_context = self.get_admin_context() # Config Methods @@ -316,6 +339,11 @@ class TestCase(test.BaseTestCase): _values.update(values) return _values + def get_ptr_fixture(self, fixture=0, values={}): + _values = copy.copy(self.ptr_fixtures[fixture]) + _values.update(values) + return _values + def get_zonefile_fixture(self, variant=None): if variant is None: f = 'example.com.zone' diff --git a/designate/tests/test_api/test_v2/test_floatingips.py b/designate/tests/test_api/test_v2/test_floatingips.py new file mode 100644 index 000000000..4c7e4c6a3 --- /dev/null +++ b/designate/tests/test_api/test_v2/test_floatingips.py @@ -0,0 +1,250 @@ +# Copyright 2013 Hewlett-Packard Development Company, L.P. +# +# Author: Endre Karlson +# +# 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. +from designate.tests.test_api.test_v2 import ApiV2TestCase + + +""" +NOTE: Record invalidation is tested in Central tests +""" + + +class ApiV2ReverseFloatingIPTest(ApiV2TestCase): + def test_get_floatingip_no_record(self): + context = self.get_context(tenant='a') + + fip = self.network_api.fake.allocate_floatingip(context.tenant) + + response = self.client.get( + '/reverse/floatingips/%s' % ":".join([fip['region'], fip['id']]), + headers={'X-Test-Tenant-Id': context.tenant_id}) + + self.assertEqual(200, response.status_int) + self.assertEqual('application/json', response.content_type) + self.assertIn('floatingip', response.json) + + #TODO(ekarlso): Remove the floatingip key - bug in v2 api + fip_record = response.json['floatingip'] + self.assertEqual(":".join([fip['region'], + fip['id']]), fip_record['id']) + self.assertEqual(fip['address'], fip_record['address']) + self.assertEqual(None, fip_record['description']) + self.assertEqual(None, fip_record['ptrdname']) + + def test_get_floatingip_with_record(self): + self.create_server() + + fixture = self.get_ptr_fixture() + + context = self.get_context(tenant='a') + + fip = self.network_api.fake.allocate_floatingip( + context.tenant) + + self.central_service.update_floatingip( + context, fip['region'], fip['id'], fixture) + + response = self.client.get( + '/reverse/floatingips/%s' % ":".join([fip['region'], fip['id']]), + headers={'X-Test-Tenant-Id': context.tenant_id}) + + self.assertEqual(200, response.status_int) + self.assertEqual('application/json', response.content_type) + self.assertIn('floatingip', response.json) + + # TODO(ekarlso): Remove the floatingip key - bug in v2 api + fip_record = response.json['floatingip'] + self.assertEqual(":".join([fip['region'], fip['id']]), + fip_record['id']) + self.assertEqual(fip['address'], fip_record['address']) + self.assertEqual(None, fip_record['description']) + self.assertEqual(fixture['ptrdname'], fip_record['ptrdname']) + + def test_get_floatingip_not_allocated(self): + url = '/reverse/floatingips/foo:04580c52-b253-4eb7-8791-fbb9de9f856f' + response = self.client.get(url, status=404) + + self.assertIn('request_id', response.json) + self.assertEqual(404, response.json['code']) + self.assertEqual('not_found', response.json['type']) + + def test_get_floatingip_invalid_key(self): + response = self.client.get('/reverse/floatingips/foo:bar', status=400) + + self.assertIn('message', response.json) + self.assertIn('request_id', response.json) + self.assertEqual(400, response.json['code']) + self.assertEqual('bad_request', response.json['type']) + + def test_list_floatingip_no_allocations(self): + response = self.client.get('/reverse/floatingips') + + self.assertIn('floatingips', response.json) + self.assertIn('links', response.json) + self.assertEqual(0, len(response.json['floatingips'])) + + def test_list_floatingip_no_record(self): + context = self.get_context(tenant='a') + + fip = self.network_api.fake.allocate_floatingip(context.tenant_id) + + response = self.client.get( + '/reverse/floatingips', + headers={'X-Test-Tenant-Id': context.tenant_id}) + + self.assertIn('floatingips', response.json) + self.assertIn('links', response.json) + self.assertEqual(1, len(response.json['floatingips'])) + + #TODO(ekarlso): Remove the floatingip key - bug in v2 api + fip_record = response.json['floatingips'][0]['floatingip'] + self.assertEqual(None, fip_record['ptrdname']) + self.assertEqual(":".join([fip['region'], fip['id']]), + fip_record['id']) + self.assertEqual(fip['address'], fip_record['address']) + self.assertEqual(None, fip_record['description']) + + def test_list_floatingip_with_record(self): + self.create_server() + + fixture = self.get_ptr_fixture() + + context = self.get_context(tenant='a') + + fip = self.network_api.fake.allocate_floatingip(context.tenant_id) + + self.central_service.update_floatingip( + context, fip['region'], fip['id'], fixture) + + response = self.client.get( + '/reverse/floatingips', + headers={'X-Test-Tenant-Id': context.tenant_id}) + + self.assertIn('floatingips', response.json) + self.assertIn('links', response.json) + self.assertEqual(1, len(response.json['floatingips'])) + + #TODO(ekarlso): Remove the floatingip key - bug in v2 api + fip_record = response.json['floatingips'][0]['floatingip'] + self.assertEqual(fixture['ptrdname'], fip_record['ptrdname']) + self.assertEqual(":".join([fip['region'], fip['id']]), + fip_record['id']) + self.assertEqual(fip['address'], fip_record['address']) + self.assertEqual(None, fip_record['description']) + self.assertEqual(fixture['ptrdname'], fip_record['ptrdname']) + + def test_set_floatingip(self): + self.create_server() + fixture = self.get_ptr_fixture() + + fip = self.network_api.fake.allocate_floatingip('tenant') + + response = self.client.patch_json( + '/reverse/floatingips/%s' % ":".join([fip['region'], fip['id']]), + {"floatingip": fixture}, + headers={'X-Test-Tenant-Id': 'tenant'}) + + self.assertEqual(200, response.status_int) + self.assertEqual('application/json', response.content_type) + self.assertIn('floatingip', response.json) + + fip_record = response.json['floatingip'] + self.assertEqual(":".join([fip['region'], fip['id']]), + fip_record['id']) + self.assertEqual(fip['address'], fip_record['address']) + self.assertEqual(None, fip_record['description']) + self.assertEqual(fixture['ptrdname'], fip_record['ptrdname']) + + def test_set_floatingip_not_allocated(self): + fixture = self.get_ptr_fixture() + + fip = self.network_api.fake.allocate_floatingip('tenant') + self.network_api.fake.deallocate_floatingip(fip['id']) + + response = self.client.patch_json( + '/reverse/floatingips/%s' % ":".join([fip['region'], fip['id']]), + {"floatingip": fixture}, status=404) + + self.assertIn('message', response.json) + self.assertIn('request_id', response.json) + self.assertEqual(404, response.json['code']) + self.assertEqual('not_found', response.json['type']) + + def test_set_floatingip_invalid_ptrdname(self): + fip = self.network_api.fake.allocate_floatingip('tenant') + + response = self.client.patch_json( + '/reverse/floatingips/%s' % ":".join([fip['region'], fip['id']]), + {"floatingip": {'ptrxname': 'test'}}, status=400) + + self.assertIn('message', response.json) + self.assertIn('request_id', response.json) + self.assertEqual(400, response.json['code']) + self.assertEqual('invalid_object', response.json['type']) + + def test_set_floatingip_invalid_key(self): + response = self.client.patch_json( + '/reverse/floatingips/%s' % 'foo:random', {}, status=400) + + self.assertIn('message', response.json) + self.assertIn('request_id', response.json) + self.assertEqual(400, response.json['code']) + self.assertEqual('bad_request', response.json['type']) + + def test_unset_floatingip(self): + self.create_server() + + fixture = self.get_ptr_fixture() + context = self.get_context(tenant='a') + + fip = self.network_api.fake.allocate_floatingip(context.tenant_id) + + # Unsetting via "None" + self.central_service.update_floatingip( + context, fip['region'], fip['id'], fixture) + + # Unset PTR ('ptrdname' is None aka null in JSON) + response = self.client.patch_json( + '/reverse/floatingips/%s' % ":".join([fip['region'], fip['id']]), + {'floatingip': {'ptrdname': None}}, + headers={'X-Test-Tenant-Id': context.tenant}) + self.assertEqual(None, response.json) + self.assertEqual(200, response.status_int) + + fip = self.central_service.get_floatingip( + context, fip['region'], fip['id']) + self.assertEqual(None, fip['ptrdname']) + + def test_unset_floatingip_not_allocated(self): + self.create_server() + + fixture = self.get_ptr_fixture() + context = self.get_context(tenant='a') + + fip = self.network_api.fake.allocate_floatingip(context.tenant) + + self.central_service.update_floatingip( + context, fip['region'], fip['id'], fixture) + + self.network_api.fake.deallocate_floatingip(fip['id']) + + response = self.client.patch_json( + '/reverse/floatingips/%s' % ":".join([fip['region'], fip['id']]), + {"floatingip": {'ptrdname': None}}, status=404) + + self.assertIn('message', response.json) + self.assertIn('request_id', response.json) + self.assertEqual(404, response.json['code']) + self.assertEqual('not_found', response.json['type']) diff --git a/designate/tests/test_central/test_service.py b/designate/tests/test_central/test_service.py index e0ebef604..4e689763a 100644 --- a/designate/tests/test_central/test_service.py +++ b/designate/tests/test_central/test_service.py @@ -1398,3 +1398,268 @@ class CentralServiceTest(CentralTestCase): with testtools.ExpectedException(exceptions.Forbidden): self.central_service.count_records(self.get_context()) + + def test_get_floatingip_no_record(self): + self.create_server() + + context = self.get_context(tenant='a') + + fip = self.network_api.fake.allocate_floatingip(context.tenant_id) + + fip_ptr = self.central_service.get_floatingip( + context, fip['region'], fip['id']) + + self.assertEqual(fip['region'], fip_ptr['region']) + self.assertEqual(fip['id'], fip_ptr['id']) + self.assertEqual(fip['address'], fip_ptr['address']) + self.assertEqual(None, fip_ptr['ptrdname']) + + def test_get_floatingip_with_record(self): + self.create_server() + + context = self.get_context(tenant='a') + + fixture = self.get_ptr_fixture() + + fip = self.network_api.fake.allocate_floatingip(context.tenant_id) + + expected = self.central_service.update_floatingip( + context, fip['region'], fip['id'], fixture) + + actual = self.central_service.get_floatingip( + context, fip['region'], fip['id']) + self.assertEqual(expected, actual) + + self.assertEqual(expected, actual) + + def test_get_floatingip_not_allocated(self): + context = self.get_context(tenant='a') + + fip = self.network_api.fake.allocate_floatingip(context.tenant_id) + self.network_api.fake.deallocate_floatingip(fip['id']) + + with testtools.ExpectedException(exceptions.NotFound): + self.central_service.get_floatingip( + context, fip['region'], fip['id']) + + def test_get_floatingip_deallocated_and_invalidate(self): + self.create_server() + + context_a = self.get_context(tenant='a') + elevated_a = context_a.elevated() + elevated_a.all_tenants = True + + context_b = self.get_context(tenant='b') + + fixture = self.get_ptr_fixture() + + # First allocate and create a FIP as tenant a + fip = self.network_api.fake.allocate_floatingip(context_a.tenant_id) + + self.central_service.update_floatingip( + context_a, fip['region'], fip['id'], fixture) + + self.network_api.fake.deallocate_floatingip(fip['id']) + + with testtools.ExpectedException(exceptions.NotFound): + self.central_service.get_floatingip( + context_a, fip['region'], fip['id']) + + # Ensure that the record is still in DB (No invalidation) + criterion = { + 'managed_resource_id': fip['id'], + 'managed_tenant_id': context_a.tenant_id} + self.central_service.find_record(elevated_a, criterion) + + # Now give the fip id to tenant 'b' and see that it get's deleted + self.network_api.fake.allocate_floatingip( + context_b.tenant_id, fip['id']) + + # There should be a fip returned with ptrdname of None + fip_ptr = self.central_service.get_floatingip( + context_b, fip['region'], fip['id']) + self.assertEqual(None, fip_ptr['ptrdname']) + + # Ensure that the old record for tenant a for the fip now owned by + # tenant b is gone + with testtools.ExpectedException(exceptions.RecordNotFound): + self.central_service.find_record(elevated_a, criterion) + + def test_list_floatingips_no_allocations(self): + context = self.get_context(tenant='a') + + fips = self.central_service.list_floatingips(context) + + self.assertEqual(0, len(fips)) + + def test_list_floatingips_no_record(self): + context = self.get_context(tenant='a') + + fip = self.network_api.fake.allocate_floatingip(context.tenant_id) + + fips = self.central_service.list_floatingips(context) + + self.assertEqual(1, len(fips)) + self.assertEqual(None, fips[0]['ptrdname']) + self.assertEqual(fip['id'], fips[0]['id']) + self.assertEqual(fip['region'], fips[0]['region']) + self.assertEqual(fip['address'], fips[0]['address']) + self.assertEqual(None, fips[0]['description']) + + def test_list_floatingips_with_record(self): + self.create_server() + + context = self.get_context(tenant='a') + + fixture = self.get_ptr_fixture() + + fip = self.network_api.fake.allocate_floatingip(context.tenant_id) + + fip_ptr = self.central_service.update_floatingip( + context, fip['region'], fip['id'], fixture) + + fips = self.central_service.list_floatingips(context) + + self.assertEqual(1, len(fips)) + self.assertEqual(fip_ptr['ptrdname'], fips[0]['ptrdname']) + self.assertEqual(fip_ptr['id'], fips[0]['id']) + self.assertEqual(fip_ptr['region'], fips[0]['region']) + self.assertEqual(fip_ptr['address'], fips[0]['address']) + self.assertEqual(fip_ptr['description'], fips[0]['description']) + + def test_list_floatingips_deallocated_and_invalidate(self): + self.create_server() + + context_a = self.get_context(tenant='a') + elevated_a = context_a.elevated() + elevated_a.all_tenants = True + + context_b = self.get_context(tenant='b') + + fixture = self.get_ptr_fixture() + + # First allocate and create a FIP as tenant a + fip = self.network_api.fake.allocate_floatingip(context_a.tenant_id) + + self.central_service.update_floatingip( + context_a, fip['region'], fip['id'], fixture) + + self.network_api.fake.deallocate_floatingip(fip['id']) + + fips = self.central_service.list_floatingips(context_a) + self.assertEqual([], fips) + + # Ensure that the record is still in DB (No invalidation) + criterion = { + 'managed_resource_id': fip['id'], + 'managed_tenant_id': context_a.tenant_id} + self.central_service.find_record(elevated_a, criterion) + + # Now give the fip id to tenant 'b' and see that it get's deleted + self.network_api.fake.allocate_floatingip( + context_b.tenant_id, fip['id']) + + # There should be a fip returned with ptrdname of None + fips = self.central_service.list_floatingips(context_b) + self.assertEqual(1, len(fips)) + self.assertEqual(None, fips[0]['ptrdname']) + + # Ensure that the old record for tenant a for the fip now owned by + # tenant b is gone + with testtools.ExpectedException(exceptions.RecordNotFound): + self.central_service.find_record(elevated_a, criterion) + + def test_set_floatingip(self): + self.create_server() + + context = self.get_context(tenant='a') + + fixture = self.get_ptr_fixture() + + fip = self.network_api.fake.allocate_floatingip(context.tenant_id) + + fip_ptr = self.central_service.update_floatingip( + context, fip['region'], fip['id'], fixture) + + self.assertEqual(fixture['ptrdname'], fip_ptr['ptrdname']) + self.assertEqual(fip['address'], fip_ptr['address']) + self.assertEqual(None, fip_ptr['description']) + self.assertIsNotNone(fip_ptr['ttl']) + + def test_set_floatingip_removes_old_rrset_and_record(self): + self.create_server() + + context_a = self.get_context(tenant='a') + elevated_a = context_a.elevated() + elevated_a.all_tenants = True + + context_b = self.get_context(tenant='b') + + fixture = self.get_ptr_fixture() + + # Test that re-setting as tenant a an already set floatingip leaves + # only 1 record + fip = self.network_api.fake.allocate_floatingip(context_a.tenant_id) + + self.central_service.update_floatingip( + context_a, fip['region'], fip['id'], fixture) + + fixture2 = self.get_ptr_fixture(fixture=1) + self.central_service.update_floatingip( + context_a, fip['region'], fip['id'], fixture2) + + count = self.central_service.count_records( + elevated_a, {'managed_resource_id': fip['id']}) + + self.assertEqual(1, count) + + self.network_api.fake.deallocate_floatingip(fip['id']) + + # Now test that tenant b allocating the same fip and setting a ptr + # deletes any records + fip = self.network_api.fake.allocate_floatingip( + context_b.tenant_id, fip['id']) + + self.central_service.update_floatingip( + context_b, fip['region'], fip['id'], fixture) + + count = self.central_service.count_records( + elevated_a, {'managed_resource_id': fip['id']}) + + self.assertEqual(1, count) + + def test_set_floatingip_not_allocated(self): + context = self.get_context(tenant='a') + fixture = self.get_ptr_fixture() + + fip = self.network_api.fake.allocate_floatingip(context.tenant_id) + self.network_api.fake.deallocate_floatingip(fip['id']) + + # If one attempts to assign a de-allocated FIP or not-owned it should + # fail with BadRequest + with testtools.ExpectedException(exceptions.NotFound): + fixture = self.central_service.update_floatingip( + context, fip['region'], fip['id'], fixture) + + def test_unset_floatingip(self): + self.create_server() + + context = self.get_context(tenant='a') + + fixture = self.get_ptr_fixture() + + fip = self.network_api.fake.allocate_floatingip(context.tenant_id) + + fip_ptr = self.central_service.update_floatingip( + context, fip['region'], fip['id'], fixture) + + self.assertEqual(fixture['ptrdname'], fip_ptr['ptrdname']) + self.assertEqual(fip['address'], fip_ptr['address']) + self.assertEqual(None, fip_ptr['description']) + self.assertIsNotNone(fip_ptr['ttl']) + + self.central_service.update_floatingip( + context, fip['region'], fip['id'], {'ptrdname': None}) + + self.central_service.get_floatingip( + context, fip['region'], fip['id']) diff --git a/designate/tests/test_network_api/__init__.py b/designate/tests/test_network_api/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/designate/tests/test_network_api/test_neutron.py b/designate/tests/test_network_api/test_neutron.py new file mode 100644 index 000000000..de286a78b --- /dev/null +++ b/designate/tests/test_network_api/test_neutron.py @@ -0,0 +1,53 @@ +# Copyright 2013 Hewlett-Packard Development Company, L.P. +# +# Author: Endre Karlson +# +# 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. +from designate import exceptions +from designate.network_api import get_api +from designate.tests import TestCase + +from neutronclient.v2_0 import client as clientv20 +from neutronclient.common import exceptions as neutron_exceptions + +from oslo.config import cfg +from mock import patch +import testtools + + +cfg.CONF.import_group('network_api:neutron', 'designate.network_api.neutron') + + +class NeutronAPITest(TestCase): + def setUp(self): + super(NeutronAPITest, self).setUp() + self.config(endpoints=['RegionOne|http://localhost:9696'], + group='network_api:neutron') + self.api = get_api('neutron') + + @patch.object(clientv20.Client, 'list_floatingips', + side_effect=neutron_exceptions.Unauthorized) + def test_unauthorized_returns_empty(self, _): + context = self.get_context(tenant='a', auth_token='test') + + fips = self.api.list_floatingips(context) + self.assertEqual(0, len(fips)) + + @patch.object(clientv20.Client, 'list_floatingips', + side_effect=neutron_exceptions.NeutronException) + def test_communication_failure(self, _): + context = self.get_context(tenant='a', auth_token='test') + + with testtools.ExpectedException( + exceptions.NeutronCommunicationFailure): + self.api.list_floatingips(context) diff --git a/doc/source/index.rst b/doc/source/index.rst index 5ecf2ec0e..1a9c2ae31 100644 --- a/doc/source/index.rst +++ b/doc/source/index.rst @@ -32,6 +32,7 @@ also be found on the `OpenStack wiki`_. production-architecture glossary backends + integrations Indices and tables diff --git a/doc/source/integrations.rst b/doc/source/integrations.rst new file mode 100644 index 000000000..86e868ea6 --- /dev/null +++ b/doc/source/integrations.rst @@ -0,0 +1,53 @@ +============ +Integrations +============ + +This page overviews integrations with other services like Neutron and others to +make use of Designate more convenient. + +Reverse - FloatingIP +==================== + +The FloatingIP PTR feature of Designate relies on information of the FloatingIP +which is in a different service then Designate itself. It can be in any service +as long as their is a "plugin" for it that can be loaded via the configuration +setting called "network_api". + +* Controller, views and schemas in the V2 API +* RPC Client towards Central used by the API and Sink +* Logic in Central to make it convenient for setting, unsetting, listing and + getting FloatingIP PTR records compared to the Records themselves which would + be more work. (This is outlined in code docstrings for the specific methods.) +* Sink handlers for the varios backend to help us be more concistent. + +Record invalidation +^^^^^^^^^^^^^^^^^^^ +Happens mainly happens via comparing a Tenant's FloatngIPs +towards the list we have of Records which are of a certain plugin type and +with the use of a Sink handler that listens for incoming events from the +various services. + +Configuring Neutron +------------------- + +Configuring the FloatingIP feature is really simple: + +[network_api:neutron] +# endpoints = RegionOne|http://localhost:9696 +# endpoint_type = publicURL +# timeout = 30 +# admin_username = designate +# admin_password = designate +# admin_tenant_name = designate +# auth_url = http://localhost:35357/v2.0 +# insecure = False +# auth_strategy = keystone +# ca_certificates_file = /etc/path/to/ca.pem + +Note that using admin_user, admin_password and admin_tenant_name is optional, +if not present we'll piggyback on the context.auth_token passed in by the API. + +.. note.. + If "endpoints" is not configured and there's no service catalog is present + in the context passed by the API to Central the request will fail in + a NoEndpoint exception. \ No newline at end of file diff --git a/etc/designate/designate.conf.sample b/etc/designate/designate.conf.sample index df818bbf7..ccd594662 100644 --- a/etc/designate/designate.conf.sample +++ b/etc/designate/designate.conf.sample @@ -22,6 +22,9 @@ debug = False # Change to "sudo" to skip the filtering and just run the comand directly root_helper = sudo +# Which networking API to use, Defaults to neutron +# network_api = neutron + ######################## ## Service Configuration ######################## @@ -51,6 +54,15 @@ root_helper = sudo # Maximum record name length #max_record_name_len = 255 + +## Managed resources settings + +# Email to use for managed resources like domains created by the FloatingIP API +# managed_resource_email = root@example.io. + +# Tenant ID to own all managed resources - like auto-created records etc. +# managed_resource_tenant_id = 123456 + #----------------------- # API Service #----------------------- @@ -99,6 +111,21 @@ root_helper = sudo # correspond to a [handler:my_driver] section below or else in the config #enabled_notification_handlers = nova_fixed +############## +## Network API +############## +[network_api:neutron] +# endpoints = RegionOne|http://localhost:9696 +# endpoint_type = publicURL +# timeout = 30 +# admin_username = designate +# admin_password = designate +# admin_tenant_name = designate +# auth_url = http://localhost:35357/v2.0 +# insecure = False +# auth_strategy = keystone +# ca_certificates_file = /etc/path/to/ca.pem + ######################## ## Storage Configuration ######################## diff --git a/requirements.txt b/requirements.txt index 375564545..970cf9159 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,6 +12,7 @@ PasteDeploy>=1.5.0 pbr>=0.5.21,<1.0 pecan>=0.2.0 python-keystoneclient>=0.3.2 +python-neutronclient>=2.3.0,<3 Routes>=1.12.3 SQLAlchemy>=0.7.8,<=0.7.99 sqlalchemy-migrate>=0.7.2 diff --git a/setup.cfg b/setup.cfg index 43a7d9b21..141c6fbb8 100644 --- a/setup.cfg +++ b/setup.cfg @@ -69,6 +69,10 @@ designate.backend = nsd4slave = designate.backend.impl_nsd4slave:NSD4SlaveBackend multi = designate.backend.impl_multi:MultiBackend +designate.network_api = + fake = designate.network_api.fake:API + neutron = designate.network_api.neutron:API + designate.quota = noop = designate.quota.impl_noop:NoopQuota storage = designate.quota.impl_storage:StorageQuota