mirror of
https://github.com/github/octodns.git
synced 2024-05-11 05:55:00 +00:00
Merge pull request #691 from ricardbejarano/hetzner-provider
Hetzner provider
This commit is contained in:
@@ -197,6 +197,7 @@ The above command pulled the existing data out of Route53 and placed the results
|
||||
| [EnvVarSource](/octodns/source/envvar.py) | | TXT | No | read-only environment variable injection |
|
||||
| [GandiProvider](/octodns/provider/gandi.py) | | A, AAAA, ALIAS, CAA, CNAME, DNAME, MX, NS, PTR, SPF, SRV, SSHFP, TXT | No | |
|
||||
| [GoogleCloudProvider](/octodns/provider/googlecloud.py) | google-cloud-dns | A, AAAA, CAA, CNAME, MX, NAPTR, NS, PTR, SPF, SRV, TXT | No | |
|
||||
| [HetznerProvider](/octodns/provider/hetzner.py) | | A, AAAA, CAA, CNAME, MX, NS, SRV, TXT | No | |
|
||||
| [MythicBeastsProvider](/octodns/provider/mythicbeasts.py) | Mythic Beasts | A, AAAA, ALIAS, CNAME, MX, NS, SRV, SSHFP, CAA, TXT | No | |
|
||||
| [Ns1Provider](/octodns/provider/ns1.py) | ns1-python | All | Yes | Missing `NA` geo target |
|
||||
| [OVH](/octodns/provider/ovh.py) | ovh | A, AAAA, CAA, CNAME, MX, NAPTR, NS, PTR, SPF, SRV, SSHFP, TXT, DKIM | No | |
|
||||
|
339
octodns/provider/hetzner.py
Normal file
339
octodns/provider/hetzner.py
Normal file
@@ -0,0 +1,339 @@
|
||||
#
|
||||
#
|
||||
#
|
||||
|
||||
from __future__ import absolute_import, division, print_function, \
|
||||
unicode_literals
|
||||
|
||||
from collections import defaultdict
|
||||
from requests import Session
|
||||
import logging
|
||||
|
||||
from ..record import Record
|
||||
from .base import BaseProvider
|
||||
|
||||
|
||||
class HetznerClientException(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class HetznerClientNotFound(HetznerClientException):
|
||||
|
||||
def __init__(self):
|
||||
super(HetznerClientNotFound, self).__init__('Not Found')
|
||||
|
||||
|
||||
class HetznerClientUnauthorized(HetznerClientException):
|
||||
|
||||
def __init__(self):
|
||||
super(HetznerClientUnauthorized, self).__init__('Unauthorized')
|
||||
|
||||
|
||||
class HetznerClient(object):
|
||||
BASE_URL = 'https://dns.hetzner.com/api/v1'
|
||||
|
||||
def __init__(self, token):
|
||||
session = Session()
|
||||
session.headers.update({'Auth-API-Token': token})
|
||||
self._session = session
|
||||
|
||||
def _do(self, method, path, params=None, data=None):
|
||||
url = '{}{}'.format(self.BASE_URL, path)
|
||||
response = self._session.request(method, url, params=params, json=data)
|
||||
if response.status_code == 401:
|
||||
raise HetznerClientUnauthorized()
|
||||
if response.status_code == 404:
|
||||
raise HetznerClientNotFound()
|
||||
response.raise_for_status()
|
||||
return response
|
||||
|
||||
def _do_json(self, method, path, params=None, data=None):
|
||||
return self._do(method, path, params, data).json()
|
||||
|
||||
def zone_get(self, name):
|
||||
params = {'name': name}
|
||||
return self._do_json('GET', '/zones', params)['zones'][0]
|
||||
|
||||
def zone_create(self, name, ttl=None):
|
||||
data = {'name': name, 'ttl': ttl}
|
||||
return self._do_json('POST', '/zones', data=data)['zone']
|
||||
|
||||
def zone_records_get(self, zone_id):
|
||||
params = {'zone_id': zone_id}
|
||||
records = self._do_json('GET', '/records', params=params)['records']
|
||||
for record in records:
|
||||
if record['name'] == '@':
|
||||
record['name'] = ''
|
||||
return records
|
||||
|
||||
def zone_record_create(self, zone_id, name, _type, value, ttl=None):
|
||||
data = {'name': name or '@', 'ttl': ttl, 'type': _type, 'value': value,
|
||||
'zone_id': zone_id}
|
||||
self._do('POST', '/records', data=data)
|
||||
|
||||
def zone_record_delete(self, zone_id, record_id):
|
||||
self._do('DELETE', '/records/{}'.format(record_id))
|
||||
|
||||
|
||||
class HetznerProvider(BaseProvider):
|
||||
'''
|
||||
Hetzner DNS provider using API v1
|
||||
|
||||
hetzner:
|
||||
class: octodns.provider.hetzner.HetznerProvider
|
||||
# Your Hetzner API token (required)
|
||||
token: foo
|
||||
'''
|
||||
SUPPORTS_GEO = False
|
||||
SUPPORTS_DYNAMIC = False
|
||||
SUPPORTS = set(('A', 'AAAA', 'CAA', 'CNAME', 'MX', 'NS', 'SRV', 'TXT'))
|
||||
|
||||
def __init__(self, id, token, *args, **kwargs):
|
||||
self.log = logging.getLogger('HetznerProvider[{}]'.format(id))
|
||||
self.log.debug('__init__: id=%s, token=***', id)
|
||||
super(HetznerProvider, self).__init__(id, *args, **kwargs)
|
||||
self._client = HetznerClient(token)
|
||||
|
||||
self._zone_records = {}
|
||||
self._zone_metadata = {}
|
||||
self._zone_name_to_id = {}
|
||||
|
||||
def _append_dot(self, value):
|
||||
if value == '@' or value[-1] == '.':
|
||||
return value
|
||||
return '{}.'.format(value)
|
||||
|
||||
def zone_metadata(self, zone_id=None, zone_name=None):
|
||||
if zone_name is not None:
|
||||
if zone_name in self._zone_name_to_id:
|
||||
zone_id = self._zone_name_to_id[zone_name]
|
||||
else:
|
||||
zone = self._client.zone_get(name=zone_name[:-1])
|
||||
zone_id = zone['id']
|
||||
self._zone_name_to_id[zone_name] = zone_id
|
||||
self._zone_metadata[zone_id] = zone
|
||||
|
||||
return self._zone_metadata[zone_id]
|
||||
|
||||
def _record_ttl(self, record):
|
||||
default_ttl = self.zone_metadata(zone_id=record['zone_id'])['ttl']
|
||||
return record['ttl'] if 'ttl' in record else default_ttl
|
||||
|
||||
def _data_for_multiple(self, _type, records):
|
||||
values = [record['value'].replace(';', '\\;') for record in records]
|
||||
return {
|
||||
'ttl': self._record_ttl(records[0]),
|
||||
'type': _type,
|
||||
'values': values
|
||||
}
|
||||
|
||||
_data_for_A = _data_for_multiple
|
||||
_data_for_AAAA = _data_for_multiple
|
||||
|
||||
def _data_for_CAA(self, _type, records):
|
||||
values = []
|
||||
for record in records:
|
||||
value_without_spaces = record['value'].replace(' ', '')
|
||||
flags = value_without_spaces[0]
|
||||
tag = value_without_spaces[1:].split('"')[0]
|
||||
value = record['value'].split('"')[1]
|
||||
values.append({
|
||||
'flags': int(flags),
|
||||
'tag': tag,
|
||||
'value': value,
|
||||
})
|
||||
return {
|
||||
'ttl': self._record_ttl(records[0]),
|
||||
'type': _type,
|
||||
'values': values
|
||||
}
|
||||
|
||||
def _data_for_CNAME(self, _type, records):
|
||||
record = records[0]
|
||||
return {
|
||||
'ttl': self._record_ttl(record),
|
||||
'type': _type,
|
||||
'value': self._append_dot(record['value'])
|
||||
}
|
||||
|
||||
def _data_for_MX(self, _type, records):
|
||||
values = []
|
||||
for record in records:
|
||||
value_stripped_split = record['value'].strip().split(' ')
|
||||
preference = value_stripped_split[0]
|
||||
exchange = value_stripped_split[-1]
|
||||
values.append({
|
||||
'preference': int(preference),
|
||||
'exchange': self._append_dot(exchange)
|
||||
})
|
||||
return {
|
||||
'ttl': self._record_ttl(records[0]),
|
||||
'type': _type,
|
||||
'values': values
|
||||
}
|
||||
|
||||
def _data_for_NS(self, _type, records):
|
||||
values = []
|
||||
for record in records:
|
||||
values.append(self._append_dot(record['value']))
|
||||
return {
|
||||
'ttl': self._record_ttl(records[0]),
|
||||
'type': _type,
|
||||
'values': values,
|
||||
}
|
||||
|
||||
def _data_for_SRV(self, _type, records):
|
||||
values = []
|
||||
for record in records:
|
||||
value_stripped = record['value'].strip()
|
||||
priority = value_stripped.split(' ')[0]
|
||||
weight = value_stripped[len(priority):].strip().split(' ')[0]
|
||||
target = value_stripped.split(' ')[-1]
|
||||
port = value_stripped[:-len(target)].strip().split(' ')[-1]
|
||||
values.append({
|
||||
'port': int(port),
|
||||
'priority': int(priority),
|
||||
'target': self._append_dot(target),
|
||||
'weight': int(weight)
|
||||
})
|
||||
return {
|
||||
'ttl': self._record_ttl(records[0]),
|
||||
'type': _type,
|
||||
'values': values
|
||||
}
|
||||
|
||||
_data_for_TXT = _data_for_multiple
|
||||
|
||||
def zone_records(self, zone):
|
||||
if zone.name not in self._zone_records:
|
||||
try:
|
||||
zone_id = self.zone_metadata(zone_name=zone.name)['id']
|
||||
self._zone_records[zone.name] = \
|
||||
self._client.zone_records_get(zone_id)
|
||||
except HetznerClientNotFound:
|
||||
return []
|
||||
|
||||
return self._zone_records[zone.name]
|
||||
|
||||
def populate(self, zone, target=False, lenient=False):
|
||||
self.log.debug('populate: name=%s, target=%s, lenient=%s', zone.name,
|
||||
target, lenient)
|
||||
|
||||
values = defaultdict(lambda: defaultdict(list))
|
||||
for record in self.zone_records(zone):
|
||||
_type = record['type']
|
||||
if _type not in self.SUPPORTS:
|
||||
self.log.warning('populate: skipping unsupported %s record',
|
||||
_type)
|
||||
continue
|
||||
values[record['name']][record['type']].append(record)
|
||||
|
||||
before = len(zone.records)
|
||||
for name, types in values.items():
|
||||
for _type, records in types.items():
|
||||
data_for = getattr(self, '_data_for_{}'.format(_type))
|
||||
record = Record.new(zone, name, data_for(_type, records),
|
||||
source=self, lenient=lenient)
|
||||
zone.add_record(record, lenient=lenient)
|
||||
|
||||
exists = zone.name in self._zone_records
|
||||
self.log.info('populate: found %s records, exists=%s',
|
||||
len(zone.records) - before, exists)
|
||||
return exists
|
||||
|
||||
def _params_for_multiple(self, record):
|
||||
for value in record.values:
|
||||
yield {
|
||||
'value': value.replace('\\;', ';'),
|
||||
'name': record.name,
|
||||
'ttl': record.ttl,
|
||||
'type': record._type
|
||||
}
|
||||
|
||||
_params_for_A = _params_for_multiple
|
||||
_params_for_AAAA = _params_for_multiple
|
||||
|
||||
def _params_for_CAA(self, record):
|
||||
for value in record.values:
|
||||
data = '{} {} "{}"'.format(value.flags, value.tag, value.value)
|
||||
yield {
|
||||
'value': data,
|
||||
'name': record.name,
|
||||
'ttl': record.ttl,
|
||||
'type': record._type
|
||||
}
|
||||
|
||||
def _params_for_single(self, record):
|
||||
yield {
|
||||
'value': record.value,
|
||||
'name': record.name,
|
||||
'ttl': record.ttl,
|
||||
'type': record._type
|
||||
}
|
||||
|
||||
_params_for_CNAME = _params_for_single
|
||||
|
||||
def _params_for_MX(self, record):
|
||||
for value in record.values:
|
||||
data = '{} {}'.format(value.preference, value.exchange)
|
||||
yield {
|
||||
'value': data,
|
||||
'name': record.name,
|
||||
'ttl': record.ttl,
|
||||
'type': record._type
|
||||
}
|
||||
|
||||
_params_for_NS = _params_for_multiple
|
||||
|
||||
def _params_for_SRV(self, record):
|
||||
for value in record.values:
|
||||
data = '{} {} {} {}'.format(value.priority, value.weight,
|
||||
value.port, value.target)
|
||||
yield {
|
||||
'value': data,
|
||||
'name': record.name,
|
||||
'ttl': record.ttl,
|
||||
'type': record._type
|
||||
}
|
||||
|
||||
_params_for_TXT = _params_for_multiple
|
||||
|
||||
def _apply_Create(self, zone_id, change):
|
||||
new = change.new
|
||||
params_for = getattr(self, '_params_for_{}'.format(new._type))
|
||||
for params in params_for(new):
|
||||
self._client.zone_record_create(zone_id, params['name'],
|
||||
params['type'], params['value'],
|
||||
params['ttl'])
|
||||
|
||||
def _apply_Update(self, zone_id, change):
|
||||
# It's way simpler to delete-then-recreate than to update
|
||||
self._apply_Delete(zone_id, change)
|
||||
self._apply_Create(zone_id, change)
|
||||
|
||||
def _apply_Delete(self, zone_id, change):
|
||||
existing = change.existing
|
||||
zone = existing.zone
|
||||
for record in self.zone_records(zone):
|
||||
if existing.name == record['name'] and \
|
||||
existing._type == record['type']:
|
||||
self._client.zone_record_delete(zone_id, record['id'])
|
||||
|
||||
def _apply(self, plan):
|
||||
desired = plan.desired
|
||||
changes = plan.changes
|
||||
self.log.debug('_apply: zone=%s, len(changes)=%d', desired.name,
|
||||
len(changes))
|
||||
|
||||
try:
|
||||
zone_id = self.zone_metadata(zone_name=desired.name)['id']
|
||||
except HetznerClientNotFound:
|
||||
self.log.debug('_apply: no matching zone, creating domain')
|
||||
zone_id = self._client.zone_create(desired.name[:-1])['id']
|
||||
|
||||
for change in changes:
|
||||
class_name = change.__class__.__name__
|
||||
getattr(self, '_apply_{}'.format(class_name))(zone_id, change)
|
||||
|
||||
# Clear out the cache if any
|
||||
self._zone_records.pop(desired.name, None)
|
223
tests/fixtures/hetzner-records.json
vendored
Normal file
223
tests/fixtures/hetzner-records.json
vendored
Normal file
@@ -0,0 +1,223 @@
|
||||
{
|
||||
"records": [
|
||||
{
|
||||
"id": "SOA",
|
||||
"type": "SOA",
|
||||
"name": "@",
|
||||
"value": "hydrogen.ns.hetzner.com. dns.hetzner.com. 1 86400 10800 3600000 3600",
|
||||
"zone_id": "unit.tests",
|
||||
"created": "0000-00-00 00:00:00.000 +0000 UTC",
|
||||
"modified": "0000-00-00 00:00:00.000 +0000 UTC"
|
||||
},
|
||||
{
|
||||
"id": "NS:sub:0",
|
||||
"type": "NS",
|
||||
"name": "sub",
|
||||
"value": "6.2.3.4",
|
||||
"ttl": 3600,
|
||||
"zone_id": "unit.tests",
|
||||
"created": "0000-00-00 00:00:00.000 +0000 UTC",
|
||||
"modified": "0000-00-00 00:00:00.000 +0000 UTC"
|
||||
},
|
||||
{
|
||||
"id": "NS:sub:1",
|
||||
"type": "NS",
|
||||
"name": "sub",
|
||||
"value": "7.2.3.4",
|
||||
"ttl": 3600,
|
||||
"zone_id": "unit.tests",
|
||||
"created": "0000-00-00 00:00:00.000 +0000 UTC",
|
||||
"modified": "0000-00-00 00:00:00.000 +0000 UTC"
|
||||
},
|
||||
{
|
||||
"id": "SRV:_srv._tcp:0",
|
||||
"type": "SRV",
|
||||
"name": "_srv._tcp",
|
||||
"value": "10 20 30 foo-1.unit.tests",
|
||||
"ttl": 600,
|
||||
"zone_id": "unit.tests",
|
||||
"created": "0000-00-00 00:00:00.000 +0000 UTC",
|
||||
"modified": "0000-00-00 00:00:00.000 +0000 UTC"
|
||||
},
|
||||
{
|
||||
"id": "SRV:_srv._tcp:1",
|
||||
"type": "SRV",
|
||||
"name": "_srv._tcp",
|
||||
"value": "12 20 30 foo-2.unit.tests",
|
||||
"ttl": 600,
|
||||
"zone_id": "unit.tests",
|
||||
"created": "0000-00-00 00:00:00.000 +0000 UTC",
|
||||
"modified": "0000-00-00 00:00:00.000 +0000 UTC"
|
||||
},
|
||||
{
|
||||
"id": "TXT:txt:0",
|
||||
"type": "TXT",
|
||||
"name": "txt",
|
||||
"value": "\"Bah bah black sheep\"",
|
||||
"ttl": 600,
|
||||
"zone_id": "unit.tests",
|
||||
"created": "0000-00-00 00:00:00.000 +0000 UTC",
|
||||
"modified": "0000-00-00 00:00:00.000 +0000 UTC"
|
||||
},
|
||||
{
|
||||
"id": "TXT:txt:1",
|
||||
"type": "TXT",
|
||||
"name": "txt",
|
||||
"value": "\"have you any wool.\"",
|
||||
"ttl": 600,
|
||||
"zone_id": "unit.tests",
|
||||
"created": "0000-00-00 00:00:00.000 +0000 UTC",
|
||||
"modified": "0000-00-00 00:00:00.000 +0000 UTC"
|
||||
},
|
||||
{
|
||||
"id": "A:@:0",
|
||||
"type": "A",
|
||||
"name": "@",
|
||||
"value": "1.2.3.4",
|
||||
"ttl": 300,
|
||||
"zone_id": "unit.tests",
|
||||
"created": "0000-00-00 00:00:00.000 +0000 UTC",
|
||||
"modified": "0000-00-00 00:00:00.000 +0000 UTC"
|
||||
},
|
||||
{
|
||||
"id": "A:@:1",
|
||||
"type": "A",
|
||||
"name": "@",
|
||||
"value": "1.2.3.5",
|
||||
"ttl": 300,
|
||||
"zone_id": "unit.tests",
|
||||
"created": "0000-00-00 00:00:00.000 +0000 UTC",
|
||||
"modified": "0000-00-00 00:00:00.000 +0000 UTC"
|
||||
},
|
||||
{
|
||||
"id": "A:www:0",
|
||||
"type": "A",
|
||||
"name": "www",
|
||||
"value": "2.2.3.6",
|
||||
"ttl": 300,
|
||||
"zone_id": "unit.tests",
|
||||
"created": "0000-00-00 00:00:00.000 +0000 UTC",
|
||||
"modified": "0000-00-00 00:00:00.000 +0000 UTC"
|
||||
},
|
||||
{
|
||||
"id": "MX:mx:0",
|
||||
"type": "MX",
|
||||
"name": "mx",
|
||||
"value": "10 smtp-4.unit.tests",
|
||||
"ttl": 300,
|
||||
"zone_id": "unit.tests",
|
||||
"created": "0000-00-00 00:00:00.000 +0000 UTC",
|
||||
"modified": "0000-00-00 00:00:00.000 +0000 UTC"
|
||||
},
|
||||
{
|
||||
"id": "MX:mx:1",
|
||||
"type": "MX",
|
||||
"name": "mx",
|
||||
"value": "20 smtp-2.unit.tests",
|
||||
"ttl": 300,
|
||||
"zone_id": "unit.tests",
|
||||
"created": "0000-00-00 00:00:00.000 +0000 UTC",
|
||||
"modified": "0000-00-00 00:00:00.000 +0000 UTC"
|
||||
},
|
||||
{
|
||||
"id": "MX:mx:2",
|
||||
"type": "MX",
|
||||
"name": "mx",
|
||||
"value": "30 smtp-3.unit.tests",
|
||||
"ttl": 300,
|
||||
"zone_id": "unit.tests",
|
||||
"created": "0000-00-00 00:00:00.000 +0000 UTC",
|
||||
"modified": "0000-00-00 00:00:00.000 +0000 UTC"
|
||||
},
|
||||
{
|
||||
"id": "MX:mx:3",
|
||||
"type": "MX",
|
||||
"name": "mx",
|
||||
"value": "40 smtp-1.unit.tests",
|
||||
"ttl": 300,
|
||||
"zone_id": "unit.tests",
|
||||
"created": "0000-00-00 00:00:00.000 +0000 UTC",
|
||||
"modified": "0000-00-00 00:00:00.000 +0000 UTC"
|
||||
},
|
||||
{
|
||||
"id": "AAAA:aaaa:0",
|
||||
"type": "AAAA",
|
||||
"name": "aaaa",
|
||||
"value": "2601:644:500:e210:62f8:1dff:feb8:947a",
|
||||
"ttl": 600,
|
||||
"zone_id": "unit.tests",
|
||||
"created": "0000-00-00 00:00:00.000 +0000 UTC",
|
||||
"modified": "0000-00-00 00:00:00.000 +0000 UTC"
|
||||
},
|
||||
{
|
||||
"id": "CNAME:cname:0",
|
||||
"type": "CNAME",
|
||||
"name": "cname",
|
||||
"value": "unit.tests",
|
||||
"ttl": 300,
|
||||
"zone_id": "unit.tests",
|
||||
"created": "0000-00-00 00:00:00.000 +0000 UTC",
|
||||
"modified": "0000-00-00 00:00:00.000 +0000 UTC"
|
||||
},
|
||||
{
|
||||
"id": "A:www.sub:0",
|
||||
"type": "A",
|
||||
"name": "www.sub",
|
||||
"value": "2.2.3.6",
|
||||
"ttl": 300,
|
||||
"zone_id": "unit.tests",
|
||||
"created": "0000-00-00 00:00:00.000 +0000 UTC",
|
||||
"modified": "0000-00-00 00:00:00.000 +0000 UTC"
|
||||
},
|
||||
{
|
||||
"id": "TXT:txt:2",
|
||||
"type": "TXT",
|
||||
"name": "txt",
|
||||
"value": "v=DKIM1;k=rsa;s=email;h=sha256;p=A/kinda+of/long/string+with+numb3rs",
|
||||
"ttl": 600,
|
||||
"zone_id": "unit.tests",
|
||||
"created": "0000-00-00 00:00:00.000 +0000 UTC",
|
||||
"modified": "0000-00-00 00:00:00.000 +0000 UTC"
|
||||
},
|
||||
{
|
||||
"id": "CAA:@:0",
|
||||
"type": "CAA",
|
||||
"name": "@",
|
||||
"value": "0 issue \"ca.unit.tests\"",
|
||||
"ttl": 3600,
|
||||
"zone_id": "unit.tests",
|
||||
"created": "0000-00-00 00:00:00.000 +0000 UTC",
|
||||
"modified": "0000-00-00 00:00:00.000 +0000 UTC"
|
||||
},
|
||||
{
|
||||
"id": "CNAME:included:0",
|
||||
"type": "CNAME",
|
||||
"name": "included",
|
||||
"value": "unit.tests",
|
||||
"ttl": 3600,
|
||||
"zone_id": "unit.tests",
|
||||
"created": "0000-00-00 00:00:00.000 +0000 UTC",
|
||||
"modified": "0000-00-00 00:00:00.000 +0000 UTC"
|
||||
},
|
||||
{
|
||||
"id": "SRV:_imap._tcp:0",
|
||||
"type": "SRV",
|
||||
"name": "_imap._tcp",
|
||||
"value": "0 0 0 .",
|
||||
"ttl": 600,
|
||||
"zone_id": "unit.tests",
|
||||
"created": "0000-00-00 00:00:00.000 +0000 UTC",
|
||||
"modified": "0000-00-00 00:00:00.000 +0000 UTC"
|
||||
},
|
||||
{
|
||||
"id": "SRV:_pop3._tcp:0",
|
||||
"type": "SRV",
|
||||
"name": "_pop3._tcp",
|
||||
"value": "0 0 0 .",
|
||||
"ttl": 600,
|
||||
"zone_id": "unit.tests",
|
||||
"created": "0000-00-00 00:00:00.000 +0000 UTC",
|
||||
"modified": "0000-00-00 00:00:00.000 +0000 UTC"
|
||||
}
|
||||
]
|
||||
}
|
43
tests/fixtures/hetzner-zones.json
vendored
Normal file
43
tests/fixtures/hetzner-zones.json
vendored
Normal file
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"zones": [
|
||||
{
|
||||
"id": "unit.tests",
|
||||
"name": "unit.tests",
|
||||
"ttl": 3600,
|
||||
"registrar": "",
|
||||
"legacy_dns_host": "",
|
||||
"legacy_ns": [],
|
||||
"ns": [],
|
||||
"created": "0000-00-00 00:00:00.000 +0000 UTC",
|
||||
"verified": "",
|
||||
"modified": "0000-00-00 00:00:00.000 +0000 UTC",
|
||||
"project": "",
|
||||
"owner": "",
|
||||
"permission": "",
|
||||
"zone_type": {
|
||||
"id": "",
|
||||
"name": "",
|
||||
"description": "",
|
||||
"prices": null
|
||||
},
|
||||
"status": "verified",
|
||||
"paused": false,
|
||||
"is_secondary_dns": false,
|
||||
"txt_verification": {
|
||||
"name": "",
|
||||
"token": ""
|
||||
},
|
||||
"records_count": null
|
||||
}
|
||||
],
|
||||
"meta": {
|
||||
"pagination": {
|
||||
"page": 1,
|
||||
"per_page": 100,
|
||||
"previous_page": 1,
|
||||
"next_page": 1,
|
||||
"last_page": 1,
|
||||
"total_entries": 1
|
||||
}
|
||||
}
|
||||
}
|
341
tests/test_octodns_provider_hetzner.py
Normal file
341
tests/test_octodns_provider_hetzner.py
Normal file
@@ -0,0 +1,341 @@
|
||||
#
|
||||
#
|
||||
#
|
||||
|
||||
|
||||
from __future__ import absolute_import, division, print_function, \
|
||||
unicode_literals
|
||||
|
||||
from mock import Mock, call
|
||||
from os.path import dirname, join
|
||||
from requests import HTTPError
|
||||
from requests_mock import ANY, mock as requests_mock
|
||||
from six import text_type
|
||||
from unittest import TestCase
|
||||
|
||||
from octodns.record import Record
|
||||
from octodns.provider.hetzner import HetznerClientNotFound, \
|
||||
HetznerProvider
|
||||
from octodns.provider.yaml import YamlProvider
|
||||
from octodns.zone import Zone
|
||||
|
||||
|
||||
class TestHetznerProvider(TestCase):
|
||||
expected = Zone('unit.tests.', [])
|
||||
source = YamlProvider('test', join(dirname(__file__), 'config'))
|
||||
source.populate(expected)
|
||||
|
||||
def test_populate(self):
|
||||
provider = HetznerProvider('test', 'token')
|
||||
|
||||
# Bad auth
|
||||
with requests_mock() as mock:
|
||||
mock.get(ANY, status_code=401,
|
||||
text='{"message":"Invalid authentication credentials"}')
|
||||
|
||||
with self.assertRaises(Exception) as ctx:
|
||||
zone = Zone('unit.tests.', [])
|
||||
provider.populate(zone)
|
||||
self.assertEquals('Unauthorized', text_type(ctx.exception))
|
||||
|
||||
# General error
|
||||
with requests_mock() as mock:
|
||||
mock.get(ANY, status_code=502, text='Things caught fire')
|
||||
|
||||
with self.assertRaises(HTTPError) as ctx:
|
||||
zone = Zone('unit.tests.', [])
|
||||
provider.populate(zone)
|
||||
self.assertEquals(502, ctx.exception.response.status_code)
|
||||
|
||||
# Non-existent zone doesn't populate anything
|
||||
with requests_mock() as mock:
|
||||
mock.get(ANY, status_code=404,
|
||||
text='{"zone":{"id":"","name":"","ttl":0,"registrar":"",'
|
||||
'"legacy_dns_host":"","legacy_ns":null,"ns":null,'
|
||||
'"created":"","verified":"","modified":"","project":"",'
|
||||
'"owner":"","permission":"","zone_type":{"id":"",'
|
||||
'"name":"","description":"","prices":null},"status":"",'
|
||||
'"paused":false,"is_secondary_dns":false,'
|
||||
'"txt_verification":{"name":"","token":""},'
|
||||
'"records_count":0},"error":{'
|
||||
'"message":"zone not found","code":404}}')
|
||||
|
||||
zone = Zone('unit.tests.', [])
|
||||
provider.populate(zone)
|
||||
self.assertEquals(set(), zone.records)
|
||||
|
||||
# No diffs == no changes
|
||||
with requests_mock() as mock:
|
||||
base = provider._client.BASE_URL
|
||||
with open('tests/fixtures/hetzner-zones.json') as fh:
|
||||
mock.get('{}/zones'.format(base), text=fh.read())
|
||||
with open('tests/fixtures/hetzner-records.json') as fh:
|
||||
mock.get('{}/records'.format(base), text=fh.read())
|
||||
|
||||
zone = Zone('unit.tests.', [])
|
||||
provider.populate(zone)
|
||||
self.assertEquals(13, len(zone.records))
|
||||
changes = self.expected.changes(zone, provider)
|
||||
self.assertEquals(0, len(changes))
|
||||
|
||||
# 2nd populate makes no network calls/all from cache
|
||||
again = Zone('unit.tests.', [])
|
||||
provider.populate(again)
|
||||
self.assertEquals(13, len(again.records))
|
||||
|
||||
# bust the cache
|
||||
del provider._zone_records[zone.name]
|
||||
|
||||
def test_apply(self):
|
||||
provider = HetznerProvider('test', 'token')
|
||||
|
||||
resp = Mock()
|
||||
resp.json = Mock()
|
||||
provider._client._do = Mock(return_value=resp)
|
||||
|
||||
domain_after_creation = {'zone': {
|
||||
'id': 'unit.tests',
|
||||
'name': 'unit.tests',
|
||||
'ttl': 3600,
|
||||
}}
|
||||
|
||||
# non-existent domain, create everything
|
||||
resp.json.side_effect = [
|
||||
HetznerClientNotFound, # no zone in populate
|
||||
HetznerClientNotFound, # no zone during apply
|
||||
domain_after_creation,
|
||||
]
|
||||
plan = provider.plan(self.expected)
|
||||
|
||||
# No root NS, no ignored, no excluded, no unsupported
|
||||
n = len(self.expected.records) - 9
|
||||
self.assertEquals(n, len(plan.changes))
|
||||
self.assertEquals(n, provider.apply(plan))
|
||||
self.assertFalse(plan.exists)
|
||||
|
||||
provider._client._do.assert_has_calls([
|
||||
# created the zone
|
||||
call('POST', '/zones', None, {
|
||||
'name': 'unit.tests',
|
||||
'ttl': None,
|
||||
}),
|
||||
# created all the records with their expected data
|
||||
call('POST', '/records', data={
|
||||
'name': '@',
|
||||
'ttl': 300,
|
||||
'type': 'A',
|
||||
'value': '1.2.3.4',
|
||||
'zone_id': 'unit.tests',
|
||||
}),
|
||||
call('POST', '/records', data={
|
||||
'name': '@',
|
||||
'ttl': 300,
|
||||
'type': 'A',
|
||||
'value': '1.2.3.5',
|
||||
'zone_id': 'unit.tests',
|
||||
}),
|
||||
call('POST', '/records', data={
|
||||
'name': '@',
|
||||
'ttl': 3600,
|
||||
'type': 'CAA',
|
||||
'value': '0 issue "ca.unit.tests"',
|
||||
'zone_id': 'unit.tests',
|
||||
}),
|
||||
call('POST', '/records', data={
|
||||
'name': '_imap._tcp',
|
||||
'ttl': 600,
|
||||
'type': 'SRV',
|
||||
'value': '0 0 0 .',
|
||||
'zone_id': 'unit.tests',
|
||||
}),
|
||||
call('POST', '/records', data={
|
||||
'name': '_pop3._tcp',
|
||||
'ttl': 600,
|
||||
'type': 'SRV',
|
||||
'value': '0 0 0 .',
|
||||
'zone_id': 'unit.tests',
|
||||
}),
|
||||
call('POST', '/records', data={
|
||||
'name': '_srv._tcp',
|
||||
'ttl': 600,
|
||||
'type': 'SRV',
|
||||
'value': '10 20 30 foo-1.unit.tests.',
|
||||
'zone_id': 'unit.tests',
|
||||
}),
|
||||
call('POST', '/records', data={
|
||||
'name': '_srv._tcp',
|
||||
'ttl': 600,
|
||||
'type': 'SRV',
|
||||
'value': '12 20 30 foo-2.unit.tests.',
|
||||
'zone_id': 'unit.tests',
|
||||
}),
|
||||
call('POST', '/records', data={
|
||||
'name': 'aaaa',
|
||||
'ttl': 600,
|
||||
'type': 'AAAA',
|
||||
'value': '2601:644:500:e210:62f8:1dff:feb8:947a',
|
||||
'zone_id': 'unit.tests',
|
||||
}),
|
||||
call('POST', '/records', data={
|
||||
'name': 'cname',
|
||||
'ttl': 300,
|
||||
'type': 'CNAME',
|
||||
'value': 'unit.tests.',
|
||||
'zone_id': 'unit.tests',
|
||||
}),
|
||||
call('POST', '/records', data={
|
||||
'name': 'included',
|
||||
'ttl': 3600,
|
||||
'type': 'CNAME',
|
||||
'value': 'unit.tests.',
|
||||
'zone_id': 'unit.tests',
|
||||
}),
|
||||
call('POST', '/records', data={
|
||||
'name': 'mx',
|
||||
'ttl': 300,
|
||||
'type': 'MX',
|
||||
'value': '10 smtp-4.unit.tests.',
|
||||
'zone_id': 'unit.tests',
|
||||
}),
|
||||
call('POST', '/records', data={
|
||||
'name': 'mx',
|
||||
'ttl': 300,
|
||||
'type': 'MX',
|
||||
'value': '20 smtp-2.unit.tests.',
|
||||
'zone_id': 'unit.tests',
|
||||
}),
|
||||
call('POST', '/records', data={
|
||||
'name': 'mx',
|
||||
'ttl': 300,
|
||||
'type': 'MX',
|
||||
'value': '30 smtp-3.unit.tests.',
|
||||
'zone_id': 'unit.tests',
|
||||
}),
|
||||
call('POST', '/records', data={
|
||||
'name': 'mx',
|
||||
'ttl': 300,
|
||||
'type': 'MX',
|
||||
'value': '40 smtp-1.unit.tests.',
|
||||
'zone_id': 'unit.tests',
|
||||
}),
|
||||
call('POST', '/records', data={
|
||||
'name': 'sub',
|
||||
'ttl': 3600,
|
||||
'type': 'NS',
|
||||
'value': '6.2.3.4.',
|
||||
'zone_id': 'unit.tests',
|
||||
}),
|
||||
call('POST', '/records', data={
|
||||
'name': 'sub',
|
||||
'ttl': 3600,
|
||||
'type': 'NS',
|
||||
'value': '7.2.3.4.',
|
||||
'zone_id': 'unit.tests',
|
||||
}),
|
||||
call('POST', '/records', data={
|
||||
'name': 'txt',
|
||||
'ttl': 600,
|
||||
'type': 'TXT',
|
||||
'value': 'Bah bah black sheep',
|
||||
'zone_id': 'unit.tests',
|
||||
}),
|
||||
call('POST', '/records', data={
|
||||
'name': 'txt',
|
||||
'ttl': 600,
|
||||
'type': 'TXT',
|
||||
'value': 'have you any wool.',
|
||||
'zone_id': 'unit.tests',
|
||||
}),
|
||||
call('POST', '/records', data={
|
||||
'name': 'txt',
|
||||
'ttl': 600,
|
||||
'type': 'TXT',
|
||||
'value': 'v=DKIM1;k=rsa;s=email;h=sha256;'
|
||||
'p=A/kinda+of/long/string+with+numb3rs',
|
||||
'zone_id': 'unit.tests',
|
||||
}),
|
||||
call('POST', '/records', data={
|
||||
'name': 'www',
|
||||
'ttl': 300,
|
||||
'type': 'A',
|
||||
'value': '2.2.3.6',
|
||||
'zone_id': 'unit.tests',
|
||||
}),
|
||||
call('POST', '/records', data={
|
||||
'name': 'www.sub',
|
||||
'ttl': 300,
|
||||
'type': 'A',
|
||||
'value': '2.2.3.6',
|
||||
'zone_id': 'unit.tests',
|
||||
}),
|
||||
])
|
||||
self.assertEquals(24, provider._client._do.call_count)
|
||||
|
||||
provider._client._do.reset_mock()
|
||||
|
||||
# delete 1 and update 1
|
||||
provider._client.zone_get = Mock(return_value={
|
||||
'id': 'unit.tests',
|
||||
'name': 'unit.tests',
|
||||
'ttl': 3600,
|
||||
})
|
||||
provider._client.zone_records_get = Mock(return_value=[
|
||||
{
|
||||
'type': 'A',
|
||||
'id': 'one',
|
||||
'created': '0000-00-00T00:00:00Z',
|
||||
'modified': '0000-00-00T00:00:00Z',
|
||||
'zone_id': 'unit.tests',
|
||||
'name': 'www',
|
||||
'value': '1.2.3.4',
|
||||
'ttl': 300,
|
||||
},
|
||||
{
|
||||
'type': 'A',
|
||||
'id': 'two',
|
||||
'created': '0000-00-00T00:00:00Z',
|
||||
'modified': '0000-00-00T00:00:00Z',
|
||||
'zone_id': 'unit.tests',
|
||||
'name': 'www',
|
||||
'value': '2.2.3.4',
|
||||
'ttl': 300,
|
||||
},
|
||||
{
|
||||
'type': 'A',
|
||||
'id': 'three',
|
||||
'created': '0000-00-00T00:00:00Z',
|
||||
'modified': '0000-00-00T00:00:00Z',
|
||||
'zone_id': 'unit.tests',
|
||||
'name': 'ttl',
|
||||
'value': '3.2.3.4',
|
||||
'ttl': 600,
|
||||
},
|
||||
])
|
||||
|
||||
# Domain exists, we don't care about return
|
||||
resp.json.side_effect = ['{}']
|
||||
|
||||
wanted = Zone('unit.tests.', [])
|
||||
wanted.add_record(Record.new(wanted, 'ttl', {
|
||||
'ttl': 300,
|
||||
'type': 'A',
|
||||
'value': '3.2.3.4',
|
||||
}))
|
||||
|
||||
plan = provider.plan(wanted)
|
||||
self.assertTrue(plan.exists)
|
||||
self.assertEquals(2, len(plan.changes))
|
||||
self.assertEquals(2, provider.apply(plan))
|
||||
# recreate for update, and delete for the 2 parts of the other
|
||||
provider._client._do.assert_has_calls([
|
||||
call('POST', '/records', data={
|
||||
'name': 'ttl',
|
||||
'ttl': 300,
|
||||
'type': 'A',
|
||||
'value': '3.2.3.4',
|
||||
'zone_id': 'unit.tests',
|
||||
}),
|
||||
call('DELETE', '/records/one'),
|
||||
call('DELETE', '/records/two'),
|
||||
call('DELETE', '/records/three'),
|
||||
], any_order=True)
|
Reference in New Issue
Block a user