mirror of
https://github.com/github/octodns.git
synced 2024-05-11 05:55:00 +00:00
339 lines
12 KiB
Python
339 lines
12 KiB
Python
#
|
|
#
|
|
#
|
|
|
|
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'(?<!\\);')
|
|
|
|
def _data_for_SPF(self, gcloud_record):
|
|
if len(gcloud_record.rrdatas) > 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
|