# Copyright 2016 Canonical Ltd # # 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 collections import contextlib import os import subprocess import uuid import charmhelpers.contrib.openstack.utils as ch_utils import charms_openstack.adapters as openstack_adapters import charms_openstack.charm as openstack_charm import charms_openstack.ip as os_ip import charmhelpers.core.decorators as decorators import charmhelpers.core.hookenv as hookenv import charmhelpers.core.host as host import charms.reactive.relations as relations from charmhelpers.contrib.network import ip as ch_ip DESIGNATE_DIR = '/etc/designate' DESIGNATE_DEFAULT = '/etc/default/openstack' DESIGNATE_CONF = DESIGNATE_DIR + '/designate.conf' RNDC_KEY_CONF = DESIGNATE_DIR + '/rndc.key' NOVA_SINK_FILE = DESIGNATE_DIR + '/conf.d/nova_sink.cfg' NEUTRON_SINK_FILE = DESIGNATE_DIR + '/conf.d/neutron_sink.cfg' RC_FILE = '/root/novarc' class DesignateDBAdapter(openstack_adapters.DatabaseRelationAdapter): """Get database URIs for the two designate databases""" @property def designate_uri(self): """URI for designate DB""" return self.get_uri(prefix='designate') @property def designate_pool_uri(self): """URI for designate pool DB""" return self.get_uri(prefix='dpm') class BindRNDCRelationAdapter(openstack_adapters.OpenStackRelationAdapter): interface_type = "dns" @property def slave_ips(self): """List of DNS slave address infoprmation @returns: list [{'unit': unitname, 'address': 'address'}, ...] """ return self.relation.slave_ips() @property def pool_config(self): """List of DNS slave information from Juju attached DNS slaves Creates a dict for each backends and returns a list of those dicts. The designate config file has a section per backend. The template uses the nameserver and pool_target names to create a section for each backend @returns: list [{'nameserver': name, 'pool_target': name, 'address': slave_ip_addr}, ...] """ pconfig = [] for slave in self.slave_ips: unit_name = slave['unit'].replace('/', '_').replace('-', '_') pconfig.append({ 'nameserver': 'nameserver_{}'.format(unit_name), 'pool_target': 'nameserver_{}'.format(unit_name), 'address': slave['address'], }) return pconfig @property def pool_targets(self): """List of pool_target section names @returns: str Comma delimited list of pool_target section names """ return ', '.join([s['pool_target'] for s in self.pool_config]) @property def slave_addresses(self): """List of slave IP addresses @returns: str Comma delimited list of slave IP addresses """ return ', '.join(['{}:53'.format(s['address']) for s in self.pool_config]) @property def rndc_info(self): """Rndc key and algorith in formation. @returns: dict {'algorithm': rndc_algorithm, 'secret': rndc_secret_digest} """ return self.relation.rndc_info class DesignateConfigurationAdapter( openstack_adapters.APIConfigurationAdapter): @property def pool_config(self): """List of DNS slave information from user defined config Creates a dict for each backends and returns a list of those dicts. The designate config file has a section per backend. The template uses the nameserver and pool_target names to create a section for each backend. @returns: list [{'nameserver': name, 'pool_target': name, 'address': slave_ip_addr, 'rndc_key_file': rndc_key_file}, ...] """ pconfig = [] if self.dns_slaves: for entry in self.dns_slaves.split(): try: address, port, key = entry.split(':') unit_name = address.replace('.', '_') pconfig.append({ 'nameserver': 'nameserver_{}'.format(unit_name), 'pool_target': 'nameserver_{}'.format(unit_name), 'address': address, 'rndc_key_file': '/etc/designate/rndc_{}.key'.format( unit_name), }) except ValueError: # the entry doesn't until 3 values, so ignore it. pass return pconfig def invalid_pool_config(self): """Validates that the pool config at least looks like something that can be used. @returns: Error string or None if okay """ if self.dns_slaves: for entry in self.dns_slaves.split(): try: _, __, ___ = entry.split(':') except ValueError: return "dns_slaves is malformed" return None @property def pool_targets(self): """List of pool_target section names @returns: str Comma delimited list of pool_target section names """ return ', '.join([s['pool_target'] for s in self.pool_config]) @property def slave_addresses(self): """List of slave IP addresses @returns: str Comma delimited list of slave IP addresses """ return ', '.join(['{}:53'.format(s['address']) for s in self.pool_config]) @property def nova_domain_id(self): """Returns the id of the domain corresponding to the user supplied 'nova-domain' @returns nova domain id """ domain = hookenv.config('nova-domain') if domain: return DesignateCharm.get_domain_id(domain) return None @property def neutron_domain_id(self): """Returns the id of the domain corresponding to the user supplied 'neutron-domain' @returns neutron domain id """ domain = hookenv.config('neutron-domain') if domain: return DesignateCharm.get_domain_id(domain) return None @property def nova_conf_args(self): """Returns config file directive to point daemons at nova config file. These directives are designed to be used in /etc/default/ files @returns startup config file option """ daemon_arg = '' if os.path.exists(NOVA_SINK_FILE): daemon_arg = '--config-file={}'.format(NOVA_SINK_FILE) return daemon_arg @property def neutron_conf_args(self): """Returns config file directive to point daemons at neutron config file. These directives are designed to be used in /etc/default/ files @returns startup config file option """ daemon_arg = '' if os.path.exists(NEUTRON_SINK_FILE): daemon_arg = '--config-file={}'.format(NEUTRON_SINK_FILE) return daemon_arg @property def rndc_master_ip(self): """Returns IP address slave DNS slave should use to query master """ return os_ip.resolve_address(endpoint_type=os_ip.INTERNAL) @property def rndc_master_ips(self): rndc_master_ips = [] rndc_master_ip = ch_ip.get_relation_ip('dns-backend') rndc_master_ips.append(rndc_master_ip) cluster_relid = hookenv.relation_ids('cluster')[0] if hookenv.related_units(relid=cluster_relid): for unit in hookenv.related_units(relid=cluster_relid): rndc_master_ip = hookenv.relation_get('rndc-address', rid=cluster_relid, unit=unit) if rndc_master_ip is not None: rndc_master_ips.append(rndc_master_ip) return rndc_master_ips class DesignateAdapters(openstack_adapters.OpenStackAPIRelationAdapters): """ Adapters class for the Designate charm. """ relation_adapters = { 'shared_db': DesignateDBAdapter, 'cluster': openstack_adapters.PeerHARelationAdapter, 'dns_backend': BindRNDCRelationAdapter, 'coordinator_memcached': openstack_adapters.MemcacheRelationAdapter, } class DesignateCharm(openstack_charm.HAOpenStackCharm): """Designate charm""" name = 'designate' packages = ['designate-agent', 'designate-api', 'designate-central', 'designate-common', 'designate-mdns', 'designate-pool-manager', 'designate-sink', 'designate-zone-manager', 'bind9utils', 'python-apt'] services = ['designate-mdns', 'designate-zone-manager', 'designate-agent', 'designate-pool-manager', 'designate-central', 'designate-sink', 'designate-api'] api_ports = { 'designate-api': { os_ip.PUBLIC: 9001, os_ip.ADMIN: 9001, os_ip.INTERNAL: 9001, } } required_relations = ['shared-db', 'amqp', 'identity-service', 'coordinator-memcached'] restart_map = { '/etc/default/openstack': services, '/etc/designate/designate.conf': services, '/etc/designate/rndc.key': services, '/etc/designate/conf.d/nova_sink.cfg': services, '/etc/designate/conf.d/neutron_sink.cfg': services, '/etc/designate/pools.yaml': [''], RC_FILE: [''], } service_type = 'designate' default_service = 'designate-api' sync_cmd = ['designate-manage', 'database', 'sync'] adapters_class = DesignateAdapters configuration_class = DesignateConfigurationAdapter ha_resources = ['vips', 'haproxy', 'dnsha'] release = 'mitaka' release_pkg = 'designate-common' package_codenames = { 'designate-common': collections.OrderedDict([ ('2', 'mitaka'), ('3', 'newton'), ('4', 'ocata'), ('5', 'pike'), ('6', 'queens'), ('7', 'rocky'), ]), } def __init__(self, release=None, **kwargs): """Custom initialiser for class If no release is passed, then the charm determines the release from the ch_utils.os_release() function. """ if release is None: release = ch_utils.os_release('python-keystonemiddleware') super(DesignateCharm, self).__init__(release=release, **kwargs) def install(self): """Customise the installation, configure the source and then call the parent install() method to install the packages """ self.configure_source() super(DesignateCharm, self).install() def render_base_config(self, interfaces_list): """Render initial config to bootstrap Designate service @returns None """ configs = [RC_FILE, DESIGNATE_CONF, RNDC_KEY_CONF, DESIGNATE_DEFAULT] if self.haproxy_enabled(): configs.append(self.HAPROXY_CONF) self.render_with_interfaces( interfaces_list, configs=configs) def render_full_config(self, interfaces_list): """Render all config for Designate service @returns None """ # Render base config first to ensure Designate API is responding as # sink configs rely on it. self.render_base_config(interfaces_list) self.render_with_interfaces(interfaces_list) def write_key_file(self, unit_name, key): """Write rndc keyfile for given unit_name @param unit_name: str Name of unit using key @param key: str RNDC key @returns None """ key_file = '/etc/designate/rndc_{}.key'.format(unit_name) template = ('key "rndc-key" {{\n algorithm hmac-md5;\n ' 'secret "{}";\n}};') host.write_file( key_file, str.encode(template.format(key)), owner='root', group='designate', perms=0o440) def render_rndc_keys(self): """Render the rndc keys supplied via user config @returns None """ slaves = hookenv.config('dns-slaves') or '' try: for entry in slaves.split(): address, port, key = entry.split(':') unit_name = address.replace('.', '_') self.write_key_file(unit_name, key) except ValueError as e: hookenv.log("Problem with 'dns-slaves' config: {}" .format(str(e)), level=hookenv.ERROR) @classmethod @decorators.retry_on_exception( 40, base_delay=5, exc_type=subprocess.CalledProcessError) def get_domain_id(cls, domain): """Return the domain ID for a given domain name @param domain: Domain name @returns domain_id """ if domain: cls.ensure_api_responding() get_cmd = ['reactive/designate_utils.py', 'domain-get', '--domain-name', domain] output = subprocess.check_output(get_cmd) if output: return output.decode('utf8').strip() return None @classmethod @decorators.retry_on_exception( 40, base_delay=5, exc_type=subprocess.CalledProcessError) def create_domain(cls, domain, email): """Create a domain @param domain: The name of the domain you are creating. The name must end with a full stop. @param email: An email address of the person responsible for the domain. @returns None """ cls.ensure_api_responding() create_cmd = ['reactive/designate_utils.py', 'domain-create', '--domain-name', domain, '--email', email] subprocess.check_call(create_cmd) @classmethod @decorators.retry_on_exception( 40, base_delay=5, exc_type=subprocess.CalledProcessError) def create_server(cls, nsname): """ create a nameserver entry with the supplied name @param nsname: Name of NameserverS record @returns None """ cls.ensure_api_responding() create_cmd = ['reactive/designate_utils.py', 'server-create', '--server-name', nsname] subprocess.check_call(create_cmd) def domain_init_done(self): """Query leader db to see if domain creation is donei @returns boolean""" return hookenv.leader_get(attribute='domain-init-done') @classmethod @decorators.retry_on_exception( 40, base_delay=5, exc_type=subprocess.CalledProcessError) def ensure_api_responding(cls): """Check that the api service is responding. The retry_on_exception decorator will cause this method to be called until it succeeds or retry limit is exceeded""" hookenv.log('Checking API service is responding', level=hookenv.WARNING) check_cmd = ['reactive/designate_utils.py', 'server-list'] subprocess.check_call(check_cmd) @classmethod @contextlib.contextmanager def check_zone_ids(cls, nova_domain_name, neutron_domain_name): zone_org_ids = { 'nova-domain-id': cls.get_domain_id(nova_domain_name), 'neutron-domain-id': cls.get_domain_id(neutron_domain_name), } yield zone_ids = { 'nova-domain-id': cls.get_domain_id(nova_domain_name), 'neutron-domain-id': cls.get_domain_id(neutron_domain_name), } if zone_org_ids != zone_ids: # Update leader-db to trigger peers to rerender configs # as sink files will need updating with new domain ids # Use host ID and current time UUID to help with debugging hookenv.leader_set({'domain-init-done': uuid.uuid1()}) @classmethod def create_initial_servers_and_domains(cls): """Create the nameserver entry and domains based on the charm user supplied config NOTE(AJK): This only wants to be done ONCE and by the leader, so we use leader settings to store that we've done it, after it's successfully completed. @returns None """ KEY = 'create_initial_servers_and_domains' if hookenv.is_leader() and not hookenv.leader_get(KEY): nova_domain_name = hookenv.config('nova-domain') neutron_domain_name = hookenv.config('neutron-domain') with cls.check_zone_ids(nova_domain_name, neutron_domain_name): if hookenv.config('nameservers'): for ns in hookenv.config('nameservers').split(): cls.create_server(ns) else: hookenv.log('No nameserver specified, skipping creation of' 'nova and neutron domains', level=hookenv.WARNING) return if nova_domain_name: cls.create_domain( nova_domain_name, hookenv.config('nova-domain-email')) if neutron_domain_name: cls.create_domain( neutron_domain_name, hookenv.config('neutron-domain-email')) # if this fails, we weren't the leader any more; another unit may # attempt to do this too. hookenv.leader_set({KEY: 'done'}) def update_pools(self): # designate-manage communicates with designate via message bus so no # need to set OS_ vars # NOTE(AJK) this runs with every hook (once most relations are up) and # so if it fails it will be picked up by the next relation change or # update-status. i.e. it will heal eventually. if hookenv.is_leader(): try: cmd = "designate-manage pool update" # Note(tinwood) that this command may fail if the pools.yaml # doesn't actually contain any pools. This happens when the # relation is broken, which errors out the charm. This stops # this happening and logs the error. subprocess.check_call(cmd.split(), timeout=60) except subprocess.CalledProcessError as e: hookenv.log("designate-manage pool update failed: {}" .format(str(e))) except subprocess.TimeoutExpired as e: # the timeout is if the rabbitmq server has gone away; it just # retries continuously; this lets the hook complete. hookenv.log("designate-manage pool command timed out: {}". format(str(e))) def custom_assess_status_check(self): if (not hookenv.config('nameservers') and (hookenv.config('nova-domain') or hookenv.config('neutron-domain'))): return 'blocked', ('nameservers must be set when specifying' ' nova-domain or neutron-domain') dns_backend_available = (relations .endpoint_from_flag('dns-backend.available')) invalid_dns = self.options.invalid_pool_config() if invalid_dns: return 'blocked', invalid_dns if not (dns_backend_available or hookenv.config('dns-slaves')): return 'blocked', ('Need either a dns-backend relation or ' 'config(dns-slaves) or both.') return None, None