mirror of
https://github.com/github/octodns.git
synced 2024-05-11 05:55:00 +00:00
449 lines
17 KiB
Python
449 lines
17 KiB
Python
#
|
|
#
|
|
#
|
|
|
|
from __future__ import absolute_import, division, print_function, \
|
|
unicode_literals
|
|
|
|
from azure.common.credentials import ServicePrincipalCredentials
|
|
from azure.mgmt.dns import DnsManagementClient
|
|
from msrestazure.azure_exceptions import CloudError
|
|
|
|
from azure.mgmt.dns.models import ARecord, AaaaRecord, CnameRecord, MxRecord, \
|
|
SrvRecord, NsRecord, PtrRecord, TxtRecord, Zone
|
|
|
|
import logging
|
|
from functools import reduce
|
|
from ..record import Record
|
|
from .base import BaseProvider
|
|
|
|
|
|
class _AzureRecord(object):
|
|
'''Wrapper for OctoDNS record for AzureProvider to make dns_client calls.
|
|
|
|
azuredns.py:
|
|
class: octodns.provider.azuredns._AzureRecord
|
|
An _AzureRecord is easily accessible to Azure DNS Management library
|
|
functions and is used to wrap all relevant data to create a record in
|
|
Azure.
|
|
'''
|
|
TYPE_MAP = {
|
|
'A': ARecord,
|
|
'AAAA': AaaaRecord,
|
|
'CNAME': CnameRecord,
|
|
'MX': MxRecord,
|
|
'SRV': SrvRecord,
|
|
'NS': NsRecord,
|
|
'PTR': PtrRecord,
|
|
'TXT': TxtRecord
|
|
}
|
|
|
|
def __init__(self, resource_group, record, delete=False):
|
|
'''Constructor for _AzureRecord.
|
|
|
|
Notes on Azure records: An Azure record set has the form
|
|
RecordSet(name=<...>, type=<...>, arecords=[...], aaaa_records, ..)
|
|
When constructing an azure record as done in self._apply_Create,
|
|
the argument parameters for an A record would be
|
|
parameters={'ttl': <int>, 'arecords': [ARecord(<str ip>),]}.
|
|
As another example for CNAME record:
|
|
parameters={'ttl': <int>, 'cname_record': CnameRecord(<str>)}.
|
|
|
|
Below, key_name and class_name are the dictionary key and Azure
|
|
Record class respectively.
|
|
|
|
:param resource_group: The name of resource group in Azure
|
|
:type resource_group: str
|
|
:param record: An OctoDNS record
|
|
:type record: ..record.Record
|
|
:param delete: If true, omit data parsing; not needed to delete
|
|
:type delete: bool
|
|
|
|
:type return: _AzureRecord
|
|
'''
|
|
self.resource_group = resource_group
|
|
self.zone_name = record.zone.name[:len(record.zone.name) - 1]
|
|
self.relative_record_set_name = record.name or '@'
|
|
self.record_type = record._type
|
|
|
|
if delete:
|
|
return
|
|
|
|
# Refer to function docstring for key_name and class_name.
|
|
format_u_s = '' if record._type == 'A' else '_'
|
|
key_name = '{}{}records'.format(self.record_type, format_u_s).lower()
|
|
if record._type == 'CNAME':
|
|
key_name = key_name[:len(key_name) - 1]
|
|
azure_class = self.TYPE_MAP[self.record_type]
|
|
|
|
self.params = getattr(self, '_params_for_{}'.format(record._type))
|
|
self.params = self.params(record.data, key_name, azure_class)
|
|
self.params['ttl'] = record.ttl
|
|
|
|
def _params(self, data, key_name, azure_class):
|
|
try:
|
|
values = data['values']
|
|
except KeyError:
|
|
values = [data['value']]
|
|
return {key_name: [azure_class(v) for v in values]}
|
|
|
|
_params_for_A = _params
|
|
_params_for_AAAA = _params
|
|
_params_for_NS = _params
|
|
_params_for_PTR = _params
|
|
|
|
def _params_for_CNAME(self, data, key_name, azure_class):
|
|
return {key_name: azure_class(data['value'])}
|
|
|
|
def _params_for_MX(self, data, key_name, azure_class):
|
|
params = []
|
|
if 'values' in data:
|
|
for vals in data['values']:
|
|
params.append(azure_class(vals['preference'],
|
|
vals['exchange']))
|
|
else: # Else there is a singular data point keyed by 'value'.
|
|
params.append(azure_class(data['value']['preference'],
|
|
data['value']['exchange']))
|
|
return {key_name: params}
|
|
|
|
def _params_for_SRV(self, data, key_name, azure_class):
|
|
params = []
|
|
if 'values' in data:
|
|
for vals in data['values']:
|
|
params.append(azure_class(vals['priority'],
|
|
vals['weight'],
|
|
vals['port'],
|
|
vals['target']))
|
|
else: # Else there is a singular data point keyed by 'value'.
|
|
params.append(azure_class(data['value']['priority'],
|
|
data['value']['weight'],
|
|
data['value']['port'],
|
|
data['value']['target']))
|
|
return {key_name: params}
|
|
|
|
def _params_for_TXT(self, data, key_name, azure_class):
|
|
try: # API for TxtRecord has list of str, even for singleton
|
|
values = data['values']
|
|
except KeyError:
|
|
values = [data['value']]
|
|
return {key_name: [azure_class([v]) for v in values]}
|
|
|
|
def _equals(self, b):
|
|
'''Checks whether two records are equal by comparing all fields.
|
|
:param b: Another _AzureRecord object
|
|
:type b: _AzureRecord
|
|
|
|
:type return: bool
|
|
'''
|
|
def parse_dict(params):
|
|
vals = []
|
|
for char in params:
|
|
if char != 'ttl':
|
|
list_records = params[char]
|
|
try:
|
|
for record in list_records:
|
|
vals.append(record.__dict__)
|
|
except:
|
|
vals.append(list_records.__dict__)
|
|
vals.sort()
|
|
return vals
|
|
|
|
return (self.resource_group == b.resource_group) & \
|
|
(self.zone_name == b.zone_name) & \
|
|
(self.record_type == b.record_type) & \
|
|
(self.params['ttl'] == b.params['ttl']) & \
|
|
(parse_dict(self.params) == parse_dict(b.params)) & \
|
|
(self.relative_record_set_name == b.relative_record_set_name)
|
|
|
|
def __str__(self):
|
|
'''String representation of an _AzureRecord.
|
|
:type return: str
|
|
'''
|
|
string = 'Zone: {}; '.format(self.zone_name)
|
|
string += 'Name: {}; '.format(self.relative_record_set_name)
|
|
string += 'Type: {}; '.format(self.record_type)
|
|
if not hasattr(self, 'params'):
|
|
return string
|
|
string += 'Ttl: {}; '.format(self.params['ttl'])
|
|
for char in self.params:
|
|
if char != 'ttl':
|
|
try:
|
|
for rec in self.params[char]:
|
|
string += 'Record: {}; '.format(rec.__dict__)
|
|
except:
|
|
string += 'Record: {}; '.format(self.params[char].__dict__)
|
|
return string
|
|
|
|
|
|
def _check_endswith_dot(string):
|
|
return string if string.endswith('.') else string + '.'
|
|
|
|
|
|
def _parse_azure_type(string):
|
|
'''Converts string representing an Azure RecordSet type to usual type.
|
|
|
|
:param string: the Azure type. eg: <Microsoft.Network/dnszones/A>
|
|
:type string: str
|
|
|
|
:type return: str
|
|
'''
|
|
return string.split('/')[len(string.split('/')) - 1]
|
|
|
|
|
|
class AzureProvider(BaseProvider):
|
|
'''
|
|
Azure DNS Provider
|
|
|
|
azuredns.py:
|
|
class: octodns.provider.azuredns.AzureProvider
|
|
# Current support of authentication of access to Azure services only
|
|
# includes using a Service Principal:
|
|
# https://docs.microsoft.com/en-us/azure/azure-resource-manager/
|
|
# resource-group-create-service-principal-portal
|
|
# The Azure Active Directory Application ID (aka client ID):
|
|
client_id:
|
|
# Authentication Key Value: (note this should be secret)
|
|
key:
|
|
# Directory ID (aka tenant ID):
|
|
directory_id:
|
|
# Subscription ID:
|
|
sub_id:
|
|
# Resource Group name:
|
|
resource_group:
|
|
# All are required to authenticate.
|
|
|
|
Example config file with variables:
|
|
"
|
|
---
|
|
providers:
|
|
config:
|
|
class: octodns.provider.yaml.YamlProvider
|
|
directory: ./config (example path to directory of zone files)
|
|
azuredns:
|
|
class: octodns.provider.azuredns.AzureProvider
|
|
client_id: env/AZURE_APPLICATION_ID
|
|
key: env/AZURE_AUTHENTICATION_KEY
|
|
directory_id: env/AZURE_DIRECTORY_ID
|
|
sub_id: env/AZURE_SUBSCRIPTION_ID
|
|
resource_group: 'TestResource1'
|
|
|
|
zones:
|
|
example.com.:
|
|
sources:
|
|
- config
|
|
targets:
|
|
- azuredns
|
|
"
|
|
The first four variables above can be hidden in environment variables
|
|
and octoDNS will automatically search for them in the shell. It is
|
|
possible to also hard-code into the config file: eg, resource_group.
|
|
'''
|
|
SUPPORTS_GEO = False
|
|
SUPPORTS = set(('A', 'AAAA', 'CNAME', 'MX', 'NS', 'PTR', 'SRV', 'TXT'))
|
|
|
|
def __init__(self, id, client_id, key, directory_id, sub_id,
|
|
resource_group, *args, **kwargs):
|
|
self.log = logging.getLogger('AzureProvider[{}]'.format(id))
|
|
self.log.debug('__init__: id=%s, client_id=%s, '
|
|
'key=***, directory_id:%s', id, client_id, directory_id)
|
|
super(AzureProvider, self).__init__(id, *args, **kwargs)
|
|
|
|
credentials = ServicePrincipalCredentials(
|
|
client_id, secret=key, tenant=directory_id
|
|
)
|
|
self._dns_client = DnsManagementClient(credentials, sub_id)
|
|
self._resource_group = resource_group
|
|
self._azure_zones = set()
|
|
|
|
def _populate_zones(self):
|
|
self.log.debug('azure_zones: loading')
|
|
list_zones = self._dns_client.zones.list_by_resource_group
|
|
for zone in list_zones(self._resource_group):
|
|
self._azure_zones.add(zone.name)
|
|
|
|
def _check_zone(self, name, create=False):
|
|
'''Checks whether a zone specified in a source exist in Azure server.
|
|
|
|
Note that Azure zones omit end '.' eg: contoso.com vs contoso.com.
|
|
Returns the name if it exists.
|
|
|
|
:param name: Name of a zone to checks
|
|
:type name: str
|
|
:param create: If True, creates the zone of that name.
|
|
:type create: bool
|
|
|
|
:type return: str or None
|
|
'''
|
|
self.log.debug('_check_zone: name=%s', name)
|
|
try:
|
|
if name in self._azure_zones:
|
|
return name
|
|
self._dns_client.zones.get(self._resource_group, name)
|
|
self._azure_zones.add(name)
|
|
return name
|
|
except CloudError as err:
|
|
msg = 'The Resource \'Microsoft.Network/dnszones/{}\''.format(name)
|
|
msg += ' under resource group \'{}\''.format(self._resource_group)
|
|
msg += ' was not found.'
|
|
if msg == err.message:
|
|
# Then the only error is that the zone doesn't currently exist
|
|
if create:
|
|
self.log.debug('_check_zone:no matching zone; creating %s',
|
|
name)
|
|
create_zone = self._dns_client.zones.create_or_update
|
|
create_zone(self._resource_group, name, Zone('global'))
|
|
return name
|
|
else:
|
|
return
|
|
raise
|
|
|
|
def populate(self, zone, target=False, lenient=False):
|
|
'''Required function of manager.py to collect records from zone.
|
|
|
|
Special notes for Azure.
|
|
Azure zone names omit final '.'
|
|
Azure root records names are represented by '@'. OctoDNS uses ''
|
|
Azure records created through online interface may have null values
|
|
(eg, no IP address for A record).
|
|
Azure online interface allows constructing records with null values
|
|
which are destroyed by _apply.
|
|
|
|
Specific quirks such as these are responsible for any non-obvious
|
|
parsing in this function and the functions '_params_for_*'.
|
|
|
|
:param zone: A dns zone
|
|
:type zone: octodns.zone.Zone
|
|
:param target: Checks if Azure is source or target of config.
|
|
Currently only supports as a 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', zone.name)
|
|
|
|
exists = False
|
|
before = len(zone.records)
|
|
|
|
zone_name = zone.name[:len(zone.name) - 1]
|
|
self._populate_zones()
|
|
self._check_zone(zone_name)
|
|
|
|
_records = set()
|
|
records = self._dns_client.record_sets.list_by_dns_zone
|
|
if self._check_zone(zone_name):
|
|
exists = True
|
|
for azrecord in records(self._resource_group, zone_name):
|
|
if _parse_azure_type(azrecord.type) in self.SUPPORTS:
|
|
_records.add(azrecord)
|
|
for azrecord in _records:
|
|
record_name = azrecord.name if azrecord.name != '@' else ''
|
|
typ = _parse_azure_type(azrecord.type)
|
|
data = getattr(self, '_data_for_{}'.format(typ))
|
|
data = data(azrecord)
|
|
data['type'] = typ
|
|
data['ttl'] = azrecord.ttl
|
|
record = Record.new(zone, record_name, data, source=self)
|
|
zone.add_record(record)
|
|
|
|
self.log.info('populate: found %s records, exists=%s',
|
|
len(zone.records) - before, exists)
|
|
return exists
|
|
|
|
def _data_for_A(self, azrecord):
|
|
return {'values': [ar.ipv4_address for ar in azrecord.arecords]}
|
|
|
|
def _data_for_AAAA(self, azrecord):
|
|
return {'values': [ar.ipv6_address for ar in azrecord.aaaa_records]}
|
|
|
|
def _data_for_CNAME(self, azrecord):
|
|
'''Parsing data from Azure DNS Client record call
|
|
:param azrecord: a return of a call to list azure records
|
|
:type azrecord: azure.mgmt.dns.models.RecordSet
|
|
|
|
:type return: dict
|
|
|
|
CNAME and PTR both use the catch block to catch possible empty
|
|
records. Refer to population comment.
|
|
'''
|
|
try:
|
|
return {'value': _check_endswith_dot(azrecord.cname_record.cname)}
|
|
except:
|
|
return {'value': '.'}
|
|
|
|
def _data_for_MX(self, azrecord):
|
|
return {'values': [{'preference': ar.preference,
|
|
'exchange': ar.exchange}
|
|
for ar in azrecord.mx_records]}
|
|
|
|
def _data_for_NS(self, azrecord):
|
|
vals = [ar.nsdname for ar in azrecord.ns_records]
|
|
return {'values': [_check_endswith_dot(val) for val in vals]}
|
|
|
|
def _data_for_PTR(self, azrecord):
|
|
try:
|
|
ptrdname = azrecord.ptr_records[0].ptrdname
|
|
return {'value': _check_endswith_dot(ptrdname)}
|
|
except:
|
|
return {'value': '.'}
|
|
|
|
def _data_for_SRV(self, azrecord):
|
|
return {'values': [{'priority': ar.priority, 'weight': ar.weight,
|
|
'port': ar.port, 'target': ar.target}
|
|
for ar in azrecord.srv_records]}
|
|
|
|
def _data_for_TXT(self, azrecord):
|
|
return {'values': [reduce((lambda a, b: a + b), ar.value)
|
|
for ar in azrecord.txt_records]}
|
|
|
|
def _apply_Create(self, change):
|
|
'''A record from change must be created.
|
|
|
|
:param change: a change object
|
|
:type change: octodns.record.Change
|
|
|
|
:type return: void
|
|
'''
|
|
ar = _AzureRecord(self._resource_group, change.new)
|
|
create = self._dns_client.record_sets.create_or_update
|
|
|
|
create(resource_group_name=ar.resource_group,
|
|
zone_name=ar.zone_name,
|
|
relative_record_set_name=ar.relative_record_set_name,
|
|
record_type=ar.record_type,
|
|
parameters=ar.params)
|
|
|
|
self.log.debug('* Success Create/Update: {}'.format(ar))
|
|
|
|
_apply_Update = _apply_Create
|
|
|
|
def _apply_Delete(self, change):
|
|
ar = _AzureRecord(self._resource_group, change.existing, delete=True)
|
|
delete = self._dns_client.record_sets.delete
|
|
|
|
delete(self._resource_group, ar.zone_name, ar.relative_record_set_name,
|
|
ar.record_type)
|
|
|
|
self.log.debug('* Success Delete: {}'.format(ar))
|
|
|
|
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))
|
|
|
|
azure_zone_name = desired.name[:len(desired.name) - 1]
|
|
self._check_zone(azure_zone_name, create=True)
|
|
|
|
for change in changes:
|
|
class_name = change.__class__.__name__
|
|
getattr(self, '_apply_{}'.format(class_name))(change)
|