From d420dd7ca1672f311f84ffffa8c60317eb3af080 Mon Sep 17 00:00:00 2001 From: Ross McFarland Date: Wed, 12 Jan 2022 07:43:36 -0800 Subject: [PATCH] Extract HetznerProvider from octoDNS core --- CHANGELOG.md | 1 + README.md | 2 +- octodns/provider/hetzner.py | 347 +------------------------ tests/fixtures/hetzner-records.json | 223 ---------------- tests/fixtures/hetzner-zones.json | 43 --- tests/test_octodns_provider_hetzner.py | 334 +----------------------- 6 files changed, 21 insertions(+), 929 deletions(-) delete mode 100644 tests/fixtures/hetzner-records.json delete mode 100644 tests/fixtures/hetzner-zones.json diff --git a/CHANGELOG.md b/CHANGELOG.md index f64a2f6..6e9856b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ * [DynProvider](https://github.com/octodns/octodns-dynprovider/) * [EasyDnsProvider](https://github.com/octodns/octodns-easydns/) * [EtcHostsProvider](https://github.com/octodns/octodns-etchosts/) + * [HetznerProvider](https://github.com/octodns/octodns-hetzner/) * [Ns1Provider](https://github.com/octodns/octodns-ns1/) * [PowerDnsProvider](https://github.com/octodns/octodns-powerdns/) * [Route53Provider](https://github.com/octodns/octodns-route53/) also diff --git a/README.md b/README.md index 9f79f57..8e43ed0 100644 --- a/README.md +++ b/README.md @@ -206,7 +206,7 @@ The table below lists the providers octoDNS supports. We're currently in the pro | [GandiProvider](/octodns/provider/gandi.py) | | | A, AAAA, ALIAS, CAA, CNAME, DNAME, MX, NS, PTR, SPF, SRV, SSHFP, TXT | No | | | [GCoreProvider](/octodns/provider/gcore.py) | | | A, AAAA, NS, MX, TXT, SRV, CNAME, PTR | Dynamic | | | [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 | | +| [HetznerProvider](https://github.com/octodns/octodns-hetzner/) | [octodns_hetzner](https://github.com/octodns/octodns-hetzner/) | | | | | | [MythicBeastsProvider](/octodns/provider/mythicbeasts.py) | | Mythic Beasts | A, AAAA, ALIAS, CNAME, MX, NS, SRV, SSHFP, CAA, TXT | No | | | [Ns1Provider](https://github.com/octodns/octodns-ns1/) | [octodns_ns1](https://github.com/octodns/octodns-ns1/) | | | | | | [OVH](/octodns/provider/ovh.py) | | ovh | A, AAAA, CAA, CNAME, MX, NAPTR, NS, PTR, SPF, SRV, SSHFP, TXT, DKIM | No | | diff --git a/octodns/provider/hetzner.py b/octodns/provider/hetzner.py index 9e4db44..07da898 100644 --- a/octodns/provider/hetzner.py +++ b/octodns/provider/hetzner.py @@ -5,336 +5,17 @@ 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 . import ProviderException -from .base import BaseProvider - - -class HetznerClientException(ProviderException): - 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 = f'{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', f'/records/{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(f'HetznerProvider[{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 f'{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, f'_data_for_{_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 = f'{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 = f'{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 = f'{value.priority} {value.weight} {value.port} ' \ - f'{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, f'_params_for_{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, f'_apply_{class_name}')(zone_id, change) - - # Clear out the cache if any - self._zone_records.pop(desired.name, None) +from logging import getLogger + +logger = getLogger('Hetzner') +try: + logger.warn('octodns_hetzner shimmed. Update your provider class to ' + 'octodns_hetzner.HetznerProvider. ' + 'Shim will be removed in 1.0') + from octodns_hetzner import HetznerProvider + HetznerProvider # pragma: no cover +except ModuleNotFoundError: + logger.exception('HetznerProvider has been moved into a seperate module, ' + 'octodns_hetzner is now required. Provider class should ' + 'be updated to octodns_hetzner.HetznerProvider') + raise diff --git a/tests/fixtures/hetzner-records.json b/tests/fixtures/hetzner-records.json deleted file mode 100644 index bbafdcb..0000000 --- a/tests/fixtures/hetzner-records.json +++ /dev/null @@ -1,223 +0,0 @@ -{ - "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" - } - ] -} diff --git a/tests/fixtures/hetzner-zones.json b/tests/fixtures/hetzner-zones.json deleted file mode 100644 index 4d9b897..0000000 --- a/tests/fixtures/hetzner-zones.json +++ /dev/null @@ -1,43 +0,0 @@ -{ - "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 - } - } -} diff --git a/tests/test_octodns_provider_hetzner.py b/tests/test_octodns_provider_hetzner.py index 0e04808..0976157 100644 --- a/tests/test_octodns_provider_hetzner.py +++ b/tests/test_octodns_provider_hetzner.py @@ -2,339 +2,15 @@ # # - 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 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', str(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(f'{base}/zones', text=fh.read()) - with open('tests/fixtures/hetzner-records.json') as fh: - mock.get(f'{base}/records', 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) - 10 - 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', - })) +class TestHetznerShim(TestCase): - 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) + def test_missing(self): + with self.assertRaises(ModuleNotFoundError): + from octodns.provider.hetzner import HetznerProvider + HetznerProvider