# # # from __future__ import absolute_import, division, print_function, \ unicode_literals import shlex import time from logging import getLogger from uuid import uuid4 import re from google.cloud import dns from .base import BaseProvider from ..record import Record class GoogleCloudProvider(BaseProvider): """ Google Cloud DNS provider google_cloud: class: octodns.provider.googlecloud.GoogleCloudProvider # Credentials file for a service_account or other account can be # specified with the GOOGLE_APPLICATION_CREDENTIALS environment # variable. (https://console.cloud.google.com/apis/credentials) # # The project to work on (not required) # project: foobar # # The File with the google credentials (not required). If used, the # "project" parameter needs to be set, else it will fall back to the # "default credentials" # credentials_file: ~/google_cloud_credentials_file.json # """ SUPPORTS = set(('A', 'AAAA', 'CAA', 'CNAME', 'MX', 'NAPTR', 'NS', 'PTR', 'SPF', 'SRV', 'TXT')) SUPPORTS_GEO = False SUPPORTS_DYNAMIC = False CHANGE_LOOP_WAIT = 5 def __init__(self, id, project=None, credentials_file=None, *args, **kwargs): if credentials_file: self.gcloud_client = dns.Client.from_service_account_json( credentials_file, project=project) else: self.gcloud_client = dns.Client(project=project) # Logger self.log = getLogger(f'GoogleCloudProvider[{id}]') self.id = id self._gcloud_zones = {} super(GoogleCloudProvider, self).__init__(id, *args, **kwargs) def _apply(self, plan): """Required function of manager.py to actually apply a record change. :param plan: Contains the zones and changes to be made :type plan: octodns.provider.base.Plan :type return: void """ desired = plan.desired changes = plan.changes self.log.debug('_apply: zone=%s, len(changes)=%d', desired.name, len(changes)) # Get gcloud zone, or create one if none existed before. if desired.name not in self.gcloud_zones: gcloud_zone = self._create_gcloud_zone(desired.name) else: gcloud_zone = self.gcloud_zones.get(desired.name) gcloud_changes = gcloud_zone.changes() for change in changes: class_name = change.__class__.__name__ _rrset_func = getattr(self, f'_rrset_for_{change.record._type}') if class_name == 'Create': gcloud_changes.add_record_set( _rrset_func(gcloud_zone, change.record)) elif class_name == 'Delete': gcloud_changes.delete_record_set( _rrset_func(gcloud_zone, change.record)) elif class_name == 'Update': gcloud_changes.delete_record_set( _rrset_func(gcloud_zone, change.existing)) gcloud_changes.add_record_set( _rrset_func(gcloud_zone, change.new)) else: msg = f'Change type "{class_name}" for change ' \ f'"{str(change)}" is none of "Create", "Delete" or "Update' raise RuntimeError(msg) gcloud_changes.create() for i in range(120): gcloud_changes.reload() # https://cloud.google.com/dns/api/v1/changes#resource # status can be one of either "pending" or "done" if gcloud_changes.status != 'pending': break self.log.debug("Waiting for changes to complete") time.sleep(self.CHANGE_LOOP_WAIT) if gcloud_changes.status != 'done': timeout = i * self.CHANGE_LOOP_WAIT raise RuntimeError(f"Timeout reached after {timeout} seconds") def _create_gcloud_zone(self, dns_name): """Creates a google cloud ManagedZone with dns_name, and zone named derived from it. calls .create() method and returns it. :param dns_name: fqdn of zone to create :type dns_name: str :type return: new google.cloud.dns.ManagedZone """ # Zone name must begin with a letter, end with a letter or digit, # and only contain lowercase letters, digits or dashes, # and be 63 characters or less zone_name = f'zone-{dns_name.replace(".", "-")}-{uuid4().hex}'[:63] gcloud_zone = self.gcloud_client.zone( name=zone_name, dns_name=dns_name ) gcloud_zone.create(client=self.gcloud_client) # add this new zone to the list of zones. self._gcloud_zones[gcloud_zone.dns_name] = gcloud_zone self.log.info(f"Created zone {zone_name}. Fqdn {dns_name}.") return gcloud_zone def _get_gcloud_records(self, gcloud_zone, page_token=None): """ Generator function which yields ResourceRecordSet for the managed gcloud zone, until there are no more records to pull. :param gcloud_zone: zone to pull records from :type gcloud_zone: google.cloud.dns.ManagedZone :param page_token: page token for the page to get :return: a resource record set :type return: google.cloud.dns.ResourceRecordSet """ gcloud_iterator = gcloud_zone.list_resource_record_sets( page_token=page_token) for gcloud_record in gcloud_iterator: yield gcloud_record # This is to get results which may be on a "paged" page. # (if more than max_results) entries. if gcloud_iterator.next_page_token: for gcloud_record in self._get_gcloud_records( gcloud_zone, gcloud_iterator.next_page_token): # yield from is in python 3 only. yield gcloud_record def _get_cloud_zones(self, page_token=None): """Load all ManagedZones into the self._gcloud_zones dict which is mapped with the dns_name as key. :return: void """ gcloud_zones = self.gcloud_client.list_zones(page_token=page_token) for gcloud_zone in gcloud_zones: self._gcloud_zones[gcloud_zone.dns_name] = gcloud_zone if gcloud_zones.next_page_token: self._get_cloud_zones(gcloud_zones.next_page_token) @property def gcloud_zones(self): if not self._gcloud_zones: self._get_cloud_zones() return self._gcloud_zones def populate(self, zone, target=False, lenient=False): """Required function of manager.py to collect records from zone. :param zone: A dns zone :type zone: octodns.zone.Zone :param target: Unused. :type target: bool :param lenient: Unused. Check octodns.manager for usage. :type lenient: bool :type return: void """ self.log.debug('populate: name=%s, target=%s, lenient=%s', zone.name, target, lenient) exists = False before = len(zone.records) gcloud_zone = self.gcloud_zones.get(zone.name) if gcloud_zone: exists = True for gcloud_record in self._get_gcloud_records(gcloud_zone): if gcloud_record.record_type.upper() not in self.SUPPORTS: continue record_name = gcloud_record.name if record_name.endswith(zone.name): # google cloud always return fqdn. Make relative record # here. "root" records will then get the '' record_name, # which is also the way octodns likes it. record_name = record_name[:-(len(zone.name) + 1)] typ = gcloud_record.record_type.upper() data = getattr(self, f'_data_for_{typ}') data = data(gcloud_record) data['type'] = typ data['ttl'] = gcloud_record.ttl self.log.debug('populate: adding record %s records: %s', record_name, data) record = Record.new(zone, record_name, data, source=self) zone.add_record(record, lenient=lenient) self.log.info('populate: found %s records, exists=%s', len(zone.records) - before, exists) return exists def _data_for_A(self, gcloud_record): return { 'values': gcloud_record.rrdatas } _data_for_AAAA = _data_for_A def _data_for_CAA(self, gcloud_record): return { 'values': [{ 'flags': v[0], 'tag': v[1], 'value': v[2]} for v in [shlex.split(g) for g in gcloud_record.rrdatas]]} def _data_for_CNAME(self, gcloud_record): return { 'value': gcloud_record.rrdatas[0] } def _data_for_MX(self, gcloud_record): return {'values': [{ "preference": v[0], "exchange": v[1]} for v in [shlex.split(g) for g in gcloud_record.rrdatas]]} def _data_for_NAPTR(self, gcloud_record): return {'values': [{ 'order': v[0], 'preference': v[1], 'flags': v[2], 'service': v[3], 'regexp': v[4], 'replacement': v[5]} for v in [shlex.split(g) for g in gcloud_record.rrdatas]]} _data_for_NS = _data_for_A _data_for_PTR = _data_for_CNAME _fix_semicolons = re.compile(r'(? 1: return { 'values': [self._fix_semicolons.sub('\\;', rr) for rr in gcloud_record.rrdatas]} return { 'value': self._fix_semicolons.sub('\\;', gcloud_record.rrdatas[0])} def _data_for_SRV(self, gcloud_record): return {'values': [{ 'priority': v[0], 'weight': v[1], 'port': v[2], 'target': v[3]} for v in [shlex.split(g) for g in gcloud_record.rrdatas]]} _data_for_TXT = _data_for_SPF def _rrset_for_A(self, gcloud_zone, record): return gcloud_zone.resource_record_set( record.fqdn, record._type, record.ttl, record.values) _rrset_for_AAAA = _rrset_for_A def _rrset_for_CAA(self, gcloud_zone, record): return gcloud_zone.resource_record_set( record.fqdn, record._type, record.ttl, [ f'{v.flags} {v.tag} {v.value}' for v in record.values]) def _rrset_for_CNAME(self, gcloud_zone, record): return gcloud_zone.resource_record_set( record.fqdn, record._type, record.ttl, [record.value]) def _rrset_for_MX(self, gcloud_zone, record): return gcloud_zone.resource_record_set( record.fqdn, record._type, record.ttl, [ f'{v.preference} {v.exchange}' for v in record.values]) def _rrset_for_NAPTR(self, gcloud_zone, record): return gcloud_zone.resource_record_set( record.fqdn, record._type, record.ttl, [ f'{v.order} {v.preference} "{v.flags}" "{v.service}" ' f'"{v.regexp}" {v.replacement}' for v in record.values]) _rrset_for_NS = _rrset_for_A _rrset_for_PTR = _rrset_for_CNAME def _rrset_for_SPF(self, gcloud_zone, record): return gcloud_zone.resource_record_set( record.fqdn, record._type, record.ttl, record.chunked_values) def _rrset_for_SRV(self, gcloud_zone, record): return gcloud_zone.resource_record_set( record.fqdn, record._type, record.ttl, [ f'{v.priority} {v.weight} {v.port} {v.target}' for v in record.values]) _rrset_for_TXT = _rrset_for_SPF