diff --git a/cirrus_designate_sink_handler/cli.py b/cirrus_designate_sink_handler/cli.py index a0069b8..db9883a 100644 --- a/cirrus_designate_sink_handler/cli.py +++ b/cirrus_designate_sink_handler/cli.py @@ -15,13 +15,13 @@ # under the License. import cirrus_designate_sink_handler.notification_handler.cirrus_floating_ip_handler as cirrus -from designate.openstack.common import log as logging +from oslo_log import log as logging from designate.utils import find_config from designate.context import DesignateContext from designate import rpc from designate import policy from keystoneclient.v2_0 import client as keystone_c -from oslo.config import cfg +from oslo_config import cfg PROG = 'designate-cirrus-sink' LOG = logging.getLogger(__name__) diff --git a/cirrus_designate_sink_handler/notification_handler/cirrus_floating_ip_handler.py b/cirrus_designate_sink_handler/notification_handler/cirrus_floating_ip_handler.py index ccb11a1..dfae188 100755 --- a/cirrus_designate_sink_handler/notification_handler/cirrus_floating_ip_handler.py +++ b/cirrus_designate_sink_handler/notification_handler/cirrus_floating_ip_handler.py @@ -3,6 +3,7 @@ # # Author: Endre Karlson # Author: Clayton O'Neill +# Updated by: Leland Lucius # # 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 @@ -17,69 +18,98 @@ # under the License. import re +import time +import designate.exceptions from designate.notification_handler.base import BaseAddressHandler -import designate.notification_handler.base -from designate.openstack.common import log as logging from designate.objects import Record -import designate.exceptions +from designate.objects import FloatingIP from designate.context import DesignateContext from keystoneclient.v2_0 import client as keystone_c from neutronclient.v2_0 import client as neutron_c from novaclient.v2 import client as nova_c -from oslo.config import cfg +from oslo_log import log as logging +from oslo_config import cfg LOG = logging.getLogger(__name__) cfg.CONF.register_group(cfg.OptGroup( name='handler:cirrus_floatingip', - title="Configuration for Cirrus Notification Handler" + title='Configuration for Cirrus Notification Handler' )) cfg.CONF.register_opts([ - cfg.ListOpt('notification-topics', default=['notifications']), + cfg.ListOpt('notification-topics', default=['notifications_designate']), cfg.StrOpt('control-exchange', default='neutron'), - cfg.StrOpt('region_name', default=None), - cfg.StrOpt('keystone_auth_uri', default=None), - cfg.StrOpt('default_regex', default='\(default\)$'), - cfg.BoolOpt('require_default_regex', default=False), + cfg.StrOpt('region-name', default=None), + cfg.StrOpt('keystone-username', default=None), + cfg.StrOpt('keystone-password', default=None), + cfg.StrOpt('keystone-auth-uri', default=None), + cfg.StrOpt('domain-id', default=None), + cfg.IntOpt('pending_delete_retries', default=60), + cfg.IntOpt('pending_delete_interval', default=1), + cfg.StrOpt('default-regex', default='\(default\)$'), + cfg.BoolOpt('require-default-regex', default=False), cfg.StrOpt('format', default='%(instance_short_name)s.%(domain)s'), - cfg.StrOpt('format_fallback', + cfg.StrOpt('format-fallback', default='%(instance_short_name)s-%(octet0)s-%(octet1)s-%(octet2)s-%(octet3)s.%(domain)s'), -], group='handler:cirrus_floating_ip') - +], group='handler:cirrus_floatingip') class CirrusRecordExists(Exception): pass - class CirrusFloatingIPHandler(BaseAddressHandler): """Handler for Neutron notifications.""" - __plugin_name__ = 'cirrus_floating_ip' + __plugin_name__ = 'cirrus_floatingip' __plugin_type__ = 'handler' def get_exchange_topics(self): exchange = cfg.CONF[self.name].control_exchange - topics = [topic for topic in cfg.CONF[self.name].notification_topics] return (exchange, topics) def get_event_types(self): return [ + 'port.update.end', + 'port.delete.end', 'floatingip.update.end', 'floatingip.delete.end', - 'port.delete.end', ] + def _get_ip_data(self, addr_dict): + data = super(CirrusFloatingIPHandler, self)._get_ip_data(addr_dict) + return data + + def _get_keystone_client(self, tenant_id): + return keystone_c.Client(auth_url=cfg.CONF[self.name].keystone_auth_uri, + username=cfg.CONF[self.name].keystone_username, + password=cfg.CONF[self.name].keystone_password, + region_name=cfg.CONF[self.name].region_name, + tenant_id=tenant_id) + + def _get_neutron_client(self, keystone_client): + endpoint = keystone_client.service_catalog.url_for(service_type='network', + endpoint_type='internalURL') + return neutron_c.Client(token=keystone_client.auth_token, + tenant_id=keystone_client.auth_tenant_id, + endpoint_url=endpoint) + + def _get_nova_client(self, keystone_client): + endpoint = keystone_client.service_catalog.url_for(service_type='compute', + endpoint_type='internalURL') + return nova_c.Client(auth_token=keystone_client.auth_token, + tenant_id=keystone_client.auth_tenant_id, + bypass_url=endpoint) + # RFC 952/1123 allow only A-Z, a-z, 0-9, and - # We'll swap all other special characters with '-', # this may lead to a collision but at least has a possibility # to work. # Additionally each section of a domain name may only be # 63 characters long, so we'll truncate that too. - def _scrub_instance_name(name=""): - scrubbed = "" + def _scrub_instance_name(self, name=''): + scrubbed = '' for char in name: if char.isalnum() or char == '.' or char == '-': scrubbed += char @@ -89,7 +119,7 @@ def _scrub_instance_name(name=""): return scrubbed return scrubbed - def _get_instance_info(self, kc, port_id): + def _get_instance_info(self, keystone_client, port_id): """Returns information about the instance associated with the neutron `port_id` given. Given a Neutron `port_id`, it will retrieve the device_id associated with @@ -100,36 +130,41 @@ def _get_instance_info(self, kc, port_id): """ - neutron_endpoint = kc.service_catalog.url_for(service_type='network', - endpoint_type='internalURL') - nc = neutron_c.Client(token=kc.auth_token, - tenant_id=kc.auth_tenant_id, - endpoint_url=neutron_endpoint) - port_details = nc.show_port(port_id) + neutron_client = self._get_neutron_client(keystone_client) + port_details = neutron_client.show_port(port_id) + instance_id = port_details['port']['device_id'] - instance_info = {'id': instance_id} + if instance_id is None or instance_id == '': + LOG.debug('device_id not yet available on %s' % port_id) + return None; + LOG.debug('Instance id for port id %s is %s' % (port_id, instance_id)) - nova_endpoint = kc.service_catalog.url_for(service_type='compute', - endpoint_type='internalURL') - nvc = nova_c.Client(auth_token=kc.auth_token, - tenant_id=kc.auth_tenant_id, - bypass_url=nova_endpoint) - server_info = nvc.servers.get(instance_id) - LOG.debug('Instance name for id %s is %s' % (instance_id, server_info.name)) - instance_info['original_name'] = server_info.name - instance_info['scrubbed_name'] = _scrub_instance_name(server_info.name) + nova_client = self._get_nova_client(keystone_client) + server = nova_client.servers.get(instance_id) + + LOG.debug('Instance name for id %s is %s' % (server.id, server.name)) + + instance_info = { + 'client': nova_client, + 'server': server, + 'id': instance_id, + 'original_name': server.name, + 'scrubbed_name': self._scrub_instance_name(server.name) + } if instance_info['original_name'] != instance_info['scrubbed_name']: LOG.warn('Instance name for id %s contains characters that cannot be used' - ' for a valid DNS record. It was scrubbed from %s to %s' - % (instance_id, instance_info['original_name'], instance_info['scrubbed_name'])) + ' for a valid DNS record. It was scrubbed from %s to %s' + % (instance_id, instance_info['original_name'], instance_info['scrubbed_name'])) instance_info['name'] = instance_info['scrubbed_name'] else: instance_info['name'] = instance_info['original_name'] + LOG.debug('instance info: %s' % instance_info) + return instance_info - def _pick_tenant_domain(self, context, default_regex, require_default_regex, metadata={}): + def _pick_tenant_domain(self, tenant_id): """Pick the appropriate domain to create floating ip records in If no appropriate domains can be found, it will return `None`. If a single @@ -137,16 +172,23 @@ def _pick_tenant_domain(self, context, default_regex, require_default_regex, met it will look for one where the description matches the regex given, and return the first match found. """ - - tenant_domains = self.central_api.find_domains(context) - if len(tenant_domains) == 1 and not require_default_regex: + tenant_context = DesignateContext(tenant=tenant_id) + + tenant_domains = self.central_api.find_domains(tenant_context) + if len(tenant_domains) == 1 and not cfg.CONF[self.name].require_default_regex: return tenant_domains[0] for domain in tenant_domains: if domain.description is not None: - if re.search(default_regex, domain.description): + if re.search(cfg.CONF[self.name].default_regex, domain.description): return domain + # Fallback to default domain if available + domain_id = cfg.CONF[self.name].domain_id + if domain_id is not None: + domain = self.get_domain(domain_id) + return domain + return None def _create(self, context, addresses, name_format, extra, domain_id, @@ -166,7 +208,7 @@ def _create(self, context, addresses, name_format, extra, domain_id, names = [] for addr in addresses: event_data = data.copy() - event_data.update(designate.notification_handler.base.get_ip_data(addr)) + event_data.update(self._get_ip_data(addr)) recordset_values = { 'domain_id': domain_id, @@ -174,14 +216,19 @@ def _create(self, context, addresses, name_format, extra, domain_id, 'type': 'A' if addr['version'] == 4 else 'AAAA' } - recordset = self._find_or_create_recordset( - context, **recordset_values) + for x in range(0, cfg.CONF[self.name].pending_delete_retries): + recordset = self._find_or_create_recordset(context, **recordset_values) + if len(recordset.records) == 0: + break - # If there is any existing A records for this name, then we don't - # want to create additional ones, we throw an exception so the - # caller can retry if appropriate. - if len(recordset.records) > 0: - raise CirrusRecordExists('Name already has an A record') + # If there is any existing A records for this name, then we don't + # want to create additional ones, we throw an exception so the + # caller can retry if appropriate. + for record in recordset.records: + if record['status'] != 'PENDING': + raise CirrusRecordExists('Name already has an A record') + + time.sleep(cfg.CONF[self.name].pending_delete_interval) record_values = { 'data': addr['address'], @@ -198,11 +245,21 @@ def _create(self, context, addresses, name_format, extra, domain_id, self.central_api.create_record(context, domain_id, recordset['id'], - Record(**record_values)) - names.append(recordset_values['name']) + Record(**record_values), + ) + values = { + 'ptrdname': recordset_values['name'], + 'description': None + } + self.central_api.update_floatingip(context, + cfg.CONF[self.name].region_name, + resource_id, + FloatingIP(**values)) + names.append({'name': recordset_values['name'], + 'addr': addr['address']}) return names - def _associate_floating_ip(self, context, domain_id, extra, floating_ip_id, floating_ip, port_id): + def _associate(self, keystone_client, payload, floatingip): """Associate a new A record with a Floating IP Try to create an A record using the format specified in the config. If @@ -217,90 +274,199 @@ def _associate_floating_ip(self, context, domain_id, extra, floating_ip_id, floa floating IP being disassociated first. """ + fip = floatingip['floating_ip_address'] + fid = floatingip['id'] + port_id = floatingip['port_id'] + + # Create an object from the original context so we can use it with the + # RPC API calls. We want this limited to the single tenant so we can + # use it to find their domains. + domain = self._pick_tenant_domain(keystone_client.tenant_id) + if domain is None: + LOG.warn('No domains found for tenant %s(%s), ignoring Floating IP update for %s' % + (keystone_client.tenant_name, keystone_client.tenant_id, fip)) + return + + LOG.info('Using domain %s(%s) for tenant %s(%s)' % + (domain.name, domain.id, + keystone_client.tenant_name, keystone_client.tenant_id)) + + instance_info = self._get_instance_info(keystone_client, port_id) + if instance_info is None: + LOG.info('Could not determine instance information for portid %s' % port_id) + return + + # We need a context that will allow us to manipulate records that are + # flagged as managed, so we can't use the context that was provided + # with the notification. + elevated_context = DesignateContext(tenant=keystone_client.tenant_id).elevated() + elevated_context.all_tenants = True + elevated_context.edit_managed_records = True + + extra = payload.copy() + extra.update({'instance_name': instance_info['name'], + 'instance_short_name': instance_info['name'].partition('.')[0], + 'project': keystone_client.tenant_name, + 'domain': domain.name}) + addresses = [{ 'version': 4, - 'address': floating_ip, + 'address': fip, }] + names = None try: - names = self._create(context=context, + names = self._create(context=elevated_context, addresses=addresses, name_format=cfg.CONF[self.name].format, extra=extra, - domain_id=domain_id, - managed_extra='portid:%s' % (port_id), + domain_id=domain.id, + managed_extra='portid:%s' % port_id, resource_type='a:floatingip', - resource_id=floating_ip_id) + resource_id=fid) except (designate.exceptions.DuplicateRecord, CirrusRecordExists): LOG.warn('Could not create record for %s using default format, ' 'trying fallback format' % (extra['instance_name'])) - names = self._create(context=context, + names = self._create(context=elevated_context, addresses=addresses, name_format=cfg.CONF[self.name].format_fallback, extra=extra, - domain_id=domain_id, - managed_extra='portid:%s' % (port_id), + domain_id=domain.id, + managed_extra='portid:%s' % port_id, resource_type='a:floatingip', - resource_id=floating_ip_id) - LOG.info("Created %s to point at %s" % (','.join(names), floating_ip)) - - def _disassociate_floating_ip(self, context, floating_ip_id): - """Remove A records associated with a given floating IP UUID - - Searches for managed A records associated with the given floating IP UUID. + resource_id=fid) + if names is not None: + client = instance_info['client'] + for name in names: + LOG.info('Created %s to point at %s' % (name['name'], name['addr'])) + # Neat idea, but instances may still be building. Try to find + # a better way of doing this...possibly queuing an even to handle + # it later. Or maybe the compute.instance.update event. + # + #client.servers.set_meta_item(instance_info['server'], + # 'hostname-%s' % name['addr'], + # name['name']) + + def _disassociate(self, tenant_id, floatingip_id=None, port_id=None): + """Remove A and associated PTR records + + Searches for managed A records based on floatingip_id or port_id + and deletes them along with any associated PTR records. """ + # We need a context that will allow us to manipulate records that are + # flagged as managed, so we can't use the context that was provided + # with the notification. + elevated_context = DesignateContext(tenant=tenant_id).elevated() + elevated_context.all_tenants = True + elevated_context.edit_managed_records = True + criterion = { 'managed': 1, 'managed_resource_type': 'a:floatingip', - 'managed_resource_id': floating_ip_id, 'managed_plugin_name': self.get_plugin_name(), 'managed_plugin_type': self.get_plugin_type(), } - records = self.central_api.find_records(context, criterion=criterion) - LOG.debug('Found %d records to delete that matched floating ip %s' % - (len(records), floating_ip_id)) - for record in records: - LOG.debug('Deleting record %s with IP %s' % (record['id'], record['data'])) - self.central_api.delete_record(context, - record['domain_id'], - record['recordset_id'], - record['id']) - LOG.info('Deleted %d records that matched floating ip %s' % - (len(records), floating_ip_id)) + if floatingip_id is not None: + criterion['managed_resource_id'] = floatingip_id + elif port_id is not None: + criterion['managed_extra'] = port_id + else: + LOG.warn('floatingip_id or port_id needed for _find_and_delete()') + return + + records = self.central_api.find_records(elevated_context, criterion=criterion) - return len(records) + LOG.debug('Found %d records to delete' % len(records)) - def _disassociate_port_id(self, context, port_id): - """Remove A records associated with a given Neutron port ID + for record in records: + LOG.debug('Deleting record %s with IP %s from %s' % (record['id'], record['data'], record['domain_id'])) - Searches for managed A records associated with the given a Neutron port - ID. This is called when an instance is deleted and we get a - port.delete.end event. Unfortunately we don't have a better place to - put it, so we look store the portid in the `managed_extra` field. + values = { + 'ptrdname': None + } + try: + self.central_api.update_floatingip(elevated_context, + record['managed_resource_region'], + record['managed_resource_id'], + FloatingIP(**values)) + except: + pass + + try: + LOG.info('domain %s recordid %s id %s' % ( + record['domain_id'], + record['recordset_id'], + record['id'])) + self.central_api.delete_record(elevated_context, + record['domain_id'], + record['recordset_id'], + record['id']) + except designate.exceptions.DomainNotFound: + pass + + def process_port_delete_end(self, context, payload): + """Process the floatingip.delete.end event + + When an instance with an associated floatingip is deleted without first + disassociating that floatingip, we never get a floatingip update event. + We just get notified that the underlying port was deleted. So, just + disassociate all floatingips assigned to the port since the port will + no longer exist. """ - criterion = { - 'managed': 1, - 'managed_resource_type': 'a:floatingip', - 'managed_extra': 'portid:%s' % (port_id), - 'managed_plugin_name': self.get_plugin_name(), - 'managed_plugin_type': self.get_plugin_type(), - } - records = self.central_api.find_records(context, criterion=criterion) - LOG.debug('Found %d records to delete that matched port id %s' % - (len(records), port_id)) - for record in records: - LOG.debug('Deleting record %s' % (record['id'])) - self.central_api.delete_record(context, - record['domain_id'], - record['recordset_id'], - record['id']) + tenant_id = context['tenant_id'] + + self._disassociate(tenant_id, port_id=payload['port_id']) + + def process_port_update_end(self, context, payload): + tenant_id = payload['port']['tenant_id'] + + # If tenant_id is blank, then a non-floatingip change was made to the port, + # so we ignore it + if tenant_id == '': + return + + port_id = payload['port']['id'] + + keystone_client = self._get_keystone_client(tenant_id) + neutron_client = self._get_neutron_client(keystone_client) - LOG.info('Deleted %d records that matched port_id %s' % - (len(records), port_id)) + floatingips = neutron_client.list_floatingips()['floatingips'] + floatingip = next( (x for x in floatingips if x['port_id'] == port_id), None) + if floatingip is None: + LOG.error('Unable to determine floatingip for port_id %s' % port_id) + return - return len(records) + self._associate(keystone_client, payload, floatingip) + + def process_floatingip_update_end(self, context, payload): + tenant_id = context['tenant_id'] + floatingip = payload['floatingip'] + + keystone_client = self._get_keystone_client(tenant_id) + + # The port_id will be None if this event is a result of disassociation. + # But, we have to always disassociate since a floatingip can be assigned + # to a new port without us being told to remove it from the previously + # assigned port. + self._disassociate(keystone_client.tenant_id, floatingip_id=floatingip['id']) + if floatingip['port_id'] is None: + return + + self._associate(keystone_client, payload, floatingip) + + def process_floatingip_delete_end(self, context, payload): + """Process the floatingip.delete.end event + + If a floatingip is associated with a port and the floatingip is deleted, + the only indication is the floatingip.delete.end event, so clean up any + records with this floatingip. + """ + + tenant_id = context['tenant_id'] + + self._disassociate(tenant_id, floatingip_id=payload['floatingip_id']) def process_notification(self, context, event_type, payload): """Process floating IP notifications from Neutron""" @@ -308,74 +474,14 @@ def process_notification(self, context, event_type, payload): LOG.info('%s received notification - %s' % (self.get_canonical_name(), event_type)) - # We need a context that will allow us to manipulate records that are - # flagged as managed, so we can't use the context that was provided - # with the notification. - elevated_context = DesignateContext(tenant=context['tenant']).elevated() - elevated_context.all_tenants = True - elevated_context.edit_managed_records = True + LOG.debug('PAYLOAD %s' % payload) - # Create an object from the original context so we can use it with the - # RPC API calls. We want this limited to the single tenant so we can - # use it to find their domains. - orig_context = DesignateContext(tenant=context['tenant']).elevated() - - # When an instance is deleted, we never get a floating IP update event, - # we just get notified that the underlying port was deleted. In that - # case look for it under the other key. - if event_type.startswith('port.delete'): - self._disassociate_port_id(context=elevated_context, - port_id=payload['port_id']) - - if event_type.startswith('floatingip.'): - # A floating IP can only be associated with a single instance at a - # time, so the first thing we always do is remove any existing - # association when we get an update. This is always safe whether - # or not we're deleting it or reassigning it. - if 'floatingip' in payload: - # floatingip.update.end - floating_ip = payload['floatingip']['floating_ip_address'] - floating_ip_id = payload['floatingip']['id'] - elif 'floatingip_id' in payload: - # floatingip.delete.end - floating_ip = None - floating_ip_id = payload['floatingip_id'] - - self._disassociate_floating_ip(context=elevated_context, - floating_ip_id=floating_ip_id, - ) + if event_type == 'port.update.end': + self.process_port_update_end(context, payload) + elif event_type == 'port.delete.end': + self.process_port_delete_end(context, payload) + elif event_type == 'floatingip.update.end': + self.process_floatingip_update_end(context, payload) + elif event_type == 'floatingip.delete.end': + self.process_floatingip_delete_end(context, payload) - # If it turns out that the event is an update and it has a fixed ip in - # the update, then we create the new record. - if event_type.startswith('floatingip.update'): - if payload['floatingip']['fixed_ip_address']: - domain = self._pick_tenant_domain(orig_context, - default_regex=cfg.CONF[self.name].default_regex, - require_default_regex=cfg.CONF[self.name].require_default_regex, - ) - if domain is None: - LOG.info('No domains found for tenant %s(%s), ignoring Floating IP update for %s' % - (context['tenant_name'], context['tenant_id'], floating_ip)) - else: - LOG.debug('Using domain %s(%s) for tenant %s(%s)' % - (domain.name, domain.id, - context['tenant_name'], context['tenant_id'])) - - kc = keystone_c.Client(token=context['auth_token'], - tenant_id=context['tenant_id'], - region_name=cfg.CONF[self.name].region_name, - auth_url=cfg.CONF[self.name].keystone_auth_uri) - - port_id = payload['floatingip']['port_id'] - instance_info = self._get_instance_info(kc, port_id) - - extra = payload.copy() - extra.update({'instance_name': instance_info['name'], - 'instance_short_name': instance_info['name'].partition('.')[0], - 'domain': domain.name}) - self._associate_floating_ip(context=elevated_context, - domain_id=domain.id, - extra=extra, - floating_ip_id=floating_ip_id, - floating_ip=floating_ip, - port_id=port_id)