1
0
mirror of https://github.com/github/octodns.git synced 2024-05-11 05:55:00 +00:00

Merge branch 'master' into ns1-fallback-only-support

This commit is contained in:
Ross McFarland
2021-05-03 12:12:14 -07:00
committed by GitHub
7 changed files with 984 additions and 6 deletions

View File

@@ -1,6 +1,29 @@
## v0.9.12 - 2021-04-30 - Enough time has passed
#### Noteworthy changes
* Formal Python 2.7 support removed, deps and tooling were becoming
unmaintainable
* octodns/octodns move, from github/octodns, more to come
#### Stuff
* ZoneFileSource supports specifying an extension & no files end in . to better
support Windows
* LOC record type support added
* Support for pre-release versions of PowerDNS
* PowerDNS delete before create which allows A <-> CNAME etc.
* Improved validation of fqdn's in ALIAS, CNAME, etc.
* Transip support for NS records
* Support for sending plan output to a file
* DNSimple uses zone api rather than domain to support non-registered stuff,
e.g. reverse zones.
* Support for fallback-only dynamic pools and related fixes to NS1 provider
* Initial Hetzner provider
## v0.9.11 - 2020-11-05 - We still don't know edition
#### Noteworthy changtes
#### Noteworthy changes
* ALIAS records only allowed at the root of zones - see `leient` in record docs
for work-arounds if you really need them.

View File

@@ -38,7 +38,7 @@ It is similar to [Netflix/denominator](https://github.com/Netflix/denominator).
Running through the following commands will install the latest release of OctoDNS and set up a place for your config files to live. To determine if provider specific requirements are necessary see the [Supported providers table](#supported-providers) below.
```
```shell
$ mkdir dns
$ cd dns
$ virtualenv env
@@ -48,6 +48,14 @@ $ pip install octodns <provider-specific-requirements>
$ mkdir config
```
#### Installing a specific commit SHA
If you'd like to install a version that has not yet been released in a repetable/safe manner you can do the following. In general octoDNS is fairly stable inbetween releases thanks to the plan and apply process, but care should be taken regardless.
```shell
$ pip install -e git+https://git@github.com/github/octodns.git@<SHA>#egg=octodns
```
### Config
We start by creating a config file to tell OctoDNS about our providers and the zone(s) we want it to manage. Below we're setting up a `YamlProvider` to source records from our config files and both a `Route53Provider` and `DynProvider` to serve as the targets for those records. You can have any number of zones set up and any number of sources of data and targets for records for each. You can also have multiple config files, that make use of separate accounts and each manage a distinct set of zones. A good example of this this might be `./config/staging.yaml` & `./config/production.yaml`. We'll focus on a `config/production.yaml`.
@@ -113,7 +121,7 @@ Further information can be found in [Records Documentation](/docs/records.md).
We're ready to do a dry-run with our new setup to see what changes it would make. Since we're pretending here we'll act like there are no existing records for `example.com.` in our accounts on either provider.
```
```shell
$ octodns-sync --config-file=./config/production.yaml
...
********************************************************************************
@@ -137,7 +145,7 @@ There will be other logging information presented on the screen, but successful
Now it's time to tell OctoDNS to make things happen. We'll invoke it again with the same options and add a `--doit` on the end to tell it this time we actually want it to try and make the specified changes.
```
```shell
$ octodns-sync --config-file=./config/production.yaml --doit
...
```
@@ -168,7 +176,7 @@ If that goes smoothly, you again see the expected changes, and verify them with
Very few situations will involve starting with a blank slate which is why there's tooling built in to pull existing data out of providers into a matching config file.
```
```shell
$ octodns-dump --config-file=config/production.yaml --output-dir=tmp/ example.com. route53
2017-03-15T13:33:34 INFO Manager __init__: config_file=tmp/production.yaml
2017-03-15T13:33:34 INFO Manager dump: zone=example.com., sources=('route53',)
@@ -197,6 +205,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 | |

View File

@@ -3,4 +3,4 @@
from __future__ import absolute_import, division, print_function, \
unicode_literals
__VERSION__ = '0.9.11'
__VERSION__ = '0.9.12'

339
octodns/provider/hetzner.py Normal file
View 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
View 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
View 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
}
}
}

View 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)