From a9beb4946dce9ecc8363552830221dc29ebbbd22 Mon Sep 17 00:00:00 2001 From: Jonatan Zint Date: Sun, 11 Dec 2022 17:46:32 +0100 Subject: [PATCH] Chore: Prepare 2.0 release This commit introduces potentially breaking changes as it adjust this plugin to be compatible with certbot >=2.0 and implements best practices from certbot2. * Remove custom hetzner client in favor for lexicon implementation * Version of this plugin 2.0 is only compatible with certbot>=2.0 * General simplification --- .github/workflows/lint.yml | 4 +- .github/workflows/publish.yml | 2 +- .github/workflows/tests.yml | 6 +- .gitignore | 1 + README.md | 6 + certbot_dns_hetzner/dns_hetzner.py | 99 ++++++----- certbot_dns_hetzner/dns_hetzner_test.py | 71 ++++---- certbot_dns_hetzner/fakes.py | 14 +- certbot_dns_hetzner/hetzner_client.py | 196 --------------------- certbot_dns_hetzner/hetzner_client_test.py | 140 --------------- codecov.yml | 6 + coverage.xml | 141 --------------- setup.py | 11 +- snap/hooks/post-refresh | 21 --- snap/snapcraft.yaml | 41 ----- tox.ini | 7 +- 16 files changed, 127 insertions(+), 639 deletions(-) delete mode 100644 certbot_dns_hetzner/hetzner_client.py delete mode 100644 certbot_dns_hetzner/hetzner_client_test.py create mode 100644 codecov.yml delete mode 100644 coverage.xml delete mode 100644 snap/hooks/post-refresh delete mode 100644 snap/snapcraft.yaml diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 7e9e1e9..06becbb 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -8,12 +8,12 @@ jobs: - name: Setup Python uses: actions/setup-python@master with: - python-version: '3.8' + python-version: '3.11' - name: Dependencies run: | python -m pip install --upgrade pip pip install -e . - name: Linting run: | - pip install pylint==2.4.4 + pip install pylint==2.15.8 python -m pylint --reports=n --rcfile=.pylintrc certbot_dns_hetzner diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index f828758..870b6f3 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -12,7 +12,7 @@ jobs: - name: Publish Python Package uses: mariamrf/py-package-publish-action@v1.0.0 with: - python_version: '3.8.0' + python_version: '3.11' env: TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index e79da0d..3c65b34 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -5,7 +5,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: [ '2.7', '3.6', '3.7', '3.8' ] + python-version: [ '3.7', '3.8', '3.9', '3.10', '3.11' ] steps: - uses: actions/checkout@master - name: Setup Python @@ -26,10 +26,10 @@ jobs: pip install pytest-cov pytest --cov=./ --cov-report=xml - name: Upload coverage to Codecov - uses: codecov/codecov-action@v1 + uses: codecov/codecov-action@v3 with: token: ${{ secrets.CODECOV_TOKEN }} - file: ./coverage.xml + files: ./coverage.xml flags: unittests name: codecov-umbrella fail_ci_if_error: true \ No newline at end of file diff --git a/.gitignore b/.gitignore index f8e60b2..92e8ba7 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,7 @@ dist*/ letsencrypt.log certbot.log letsencrypt-auto-source/letsencrypt-auto.sig.lzma.base64 +coverage.xml # coverage .coverage diff --git a/README.md b/README.md index 671d8af..546c9fb 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,12 @@ This certbot plugin automates the process of completing a dns-01 challenge by creating, and subsequently removing, TXT records using the Hetzner DNS API. +## Requirements + +### For certbot < 2 + +Notice that this plugin is only supporting certbot>=2.0 from 2.0 onwards. For older certbot versions use 1.x releases. + ## Install Install this package via pip in the same python environment where you installed your certbot. diff --git a/certbot_dns_hetzner/dns_hetzner.py b/certbot_dns_hetzner/dns_hetzner.py index e76ecc6..a4cbe8d 100644 --- a/certbot_dns_hetzner/dns_hetzner.py +++ b/certbot_dns_hetzner/dns_hetzner.py @@ -1,14 +1,7 @@ """DNS Authenticator for Hetzner DNS.""" -import requests - - -from certbot import errors -from certbot.plugins import dns_common - -from certbot_dns_hetzner.hetzner_client import \ - _MalformedResponseException, \ - _HetznerClient, \ - _ZoneNotFoundException, _NotAuthorizedException +import tldextract +from certbot.plugins import dns_common, dns_common_lexicon +from lexicon.providers import hetzner TTL = 60 @@ -18,60 +11,76 @@ class Authenticator(dns_common.DNSAuthenticator): This Authenticator uses the Hetzner DNS API to fulfill a dns-01 challenge. """ - description = 'Obtain certificates using a DNS TXT record (if you are using Hetzner for DNS).' + description = ( + "Obtain certificates using a DNS TXT record (if you are using Hetzner for DNS)." + ) def __init__(self, *args, **kwargs): - super(Authenticator, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) self.credentials = None @classmethod def add_parser_arguments(cls, add): # pylint: disable=arguments-differ - super(Authenticator, cls).add_parser_arguments(add, default_propagation_seconds=60) - add('credentials', help='Hetzner credentials INI file.') + super(Authenticator, cls).add_parser_arguments( + add, default_propagation_seconds=60 + ) + add("credentials", help="Hetzner credentials INI file.") - def more_info(self): # pylint: disable=missing-function-docstring,no-self-use - return 'This plugin configures a DNS TXT record to respond to a dns-01 challenge using ' + \ - 'the Hetzner API.' + def more_info(self): # pylint: disable=missing-function-docstring + return ( + "This plugin configures a DNS TXT record to respond to a dns-01 challenge using " + + "the Hetzner API." + ) def _setup_credentials(self): self.credentials = self._configure_credentials( - 'credentials', - 'Hetzner credentials INI file', + "credentials", + "Hetzner credentials INI file", { - 'api_token': 'Hetzner API Token from \'https://dns.hetzner.com/settings/api-token\'', - } + "api_token": "Hetzner API Token from 'https://dns.hetzner.com/settings/api-token'", + }, ) + @staticmethod + def _get_zone(domain): + zone_name = tldextract.extract(domain) + return '.'.join([zone_name.domain, zone_name.suffix]) + def _perform(self, domain, validation_name, validation): - try: - self._get_hetzner_client().add_record( - domain, - "TXT", - self._fqdn_format(validation_name), - validation, - TTL - ) - except ( - _ZoneNotFoundException, - requests.ConnectionError, - _MalformedResponseException, - _NotAuthorizedException - ) as exception: - raise errors.PluginError(exception) + self._get_hetzner_client().add_txt_record( + self._get_zone(domain), + self._fqdn_format(validation_name), + validation + ) def _cleanup(self, domain, validation_name, validation): - try: - self._get_hetzner_client().delete_record_by_name(domain, self._fqdn_format(validation_name)) - except (requests.ConnectionError, _NotAuthorizedException) as exception: - raise errors.PluginError(exception) + self._get_hetzner_client().del_txt_record( + self._get_zone(domain), + self._fqdn_format(validation_name), + validation + ) def _get_hetzner_client(self): - return _HetznerClient( - self.credentials.conf('api_token'), - ) + return _HetznerClient(self.credentials.conf("api_token")) @staticmethod def _fqdn_format(name): - if not name.endswith('.'): - return '{0}.'.format(name) + if not name.endswith("."): + return f"{name}." return name + + +class _HetznerClient(dns_common_lexicon.LexiconClient): + """ + Encapsulates all communication with the Hetzner via Lexicon. + """ + def __init__(self, auth_token): + super().__init__() + + config = dns_common_lexicon.build_lexicon_config('hetzner', { + 'ttl': TTL, + }, { + 'auth_token': auth_token, + }) + + self.provider = hetzner.Provider(config) diff --git a/certbot_dns_hetzner/dns_hetzner_test.py b/certbot_dns_hetzner/dns_hetzner_test.py index 9424ad8..9e47cdb 100644 --- a/certbot_dns_hetzner/dns_hetzner_test.py +++ b/certbot_dns_hetzner/dns_hetzner_test.py @@ -1,4 +1,4 @@ -"""Tests for certbot_dns_ispconfig.dns_ispconfig.""" +"""Tests for certbot_dns_hetzner.dns_hetzner.""" import unittest @@ -10,85 +10,88 @@ from certbot.plugins.dns_test_common import DOMAIN from certbot.tests import util as test_util -try: - # certbot 1.18+ - patch_display_util = test_util.patch_display_util -except AttributeError: - # certbot <= 1.17 - patch_display_util = test_util.patch_get_utility - from certbot_dns_hetzner.fakes import FAKE_API_TOKEN, FAKE_RECORD -from certbot_dns_hetzner.hetzner_client import _ZoneNotFoundException + + + +patch_display_util = test_util.patch_display_util class AuthenticatorTest( - test_util.TempDirTestCase, - dns_test_common.BaseAuthenticatorTest + test_util.TempDirTestCase, dns_test_common.BaseAuthenticatorTest ): """ Test for Hetzner DNS Authenticator """ + def setUp(self): - super(AuthenticatorTest, self).setUp() + super().setUp() from certbot_dns_hetzner.dns_hetzner import Authenticator # pylint: disable=import-outside-toplevel - path = os.path.join(self.tempdir, 'fake_credentials.ini') + path = os.path.join(self.tempdir, "fake_credentials.ini") dns_test_common.write( { - 'hetzner_api_token': FAKE_API_TOKEN, + "hetzner_api_token": FAKE_API_TOKEN, }, path, ) - super(AuthenticatorTest, self).setUp() + super().setUp() self.config = mock.MagicMock( hetzner_credentials=path, hetzner_propagation_seconds=0 ) # don't wait during tests - self.auth = Authenticator(self.config, 'hetzner') + self.auth = Authenticator(self.config, "hetzner") self.mock_client = mock.MagicMock() # _get_ispconfig_client | pylint: disable=protected-access - self.auth._get_hetzner_client = mock.MagicMock(return_value=self.mock_client) + self.auth._get_hetzner_client = mock.MagicMock( + return_value=self.mock_client) @patch_display_util() def test_perform(self, unused_mock_get_utility): - self.mock_client.add_record.return_value = FAKE_RECORD + self.mock_client.add_txt_record.return_value = FAKE_RECORD self.auth.perform([self.achall]) - self.mock_client.add_record.assert_called_with( - DOMAIN, 'TXT', '_acme-challenge.' + DOMAIN + '.', mock.ANY, mock.ANY + self.mock_client.add_txt_record.assert_called_with( + DOMAIN, "_acme-challenge." + DOMAIN + ".", mock.ANY ) - def test_perform_but_raises_zone_not_found(self): - self.mock_client.add_record.side_effect = mock.MagicMock(side_effect=_ZoneNotFoundException(DOMAIN)) - self.assertRaises( - PluginError, - self.auth.perform, [self.achall] + def test_perform_but_raises_plugin_error(self): + self.mock_client.add_txt_record.side_effect = mock.MagicMock( + side_effect=PluginError() ) - self.mock_client.add_record.assert_called_with( - DOMAIN, 'TXT', '_acme-challenge.' + DOMAIN + '.', mock.ANY, mock.ANY + self.assertRaises(PluginError, self.auth.perform, [self.achall]) + self.mock_client.add_txt_record.assert_called_with( + DOMAIN, "_acme-challenge." + DOMAIN + ".", mock.ANY ) @patch_display_util() def test_cleanup(self, unused_mock_get_utility): - self.mock_client.add_record.return_value = FAKE_RECORD + self.mock_client.add_txt_record.return_value = FAKE_RECORD # _attempt_cleanup | pylint: disable=protected-access self.auth.perform([self.achall]) self.auth._attempt_cleanup = True self.auth.cleanup([self.achall]) - self.mock_client.delete_record_by_name.assert_called_with(DOMAIN, '_acme-challenge.' + DOMAIN + '.') + self.mock_client.del_txt_record.assert_called_with( + DOMAIN, "_acme-challenge." + DOMAIN + ".", mock.ANY + ) @patch_display_util() - def test_cleanup_but_connection_aborts(self, unused_mock_get_utility): - self.mock_client.add_record.return_value = FAKE_RECORD + def test_cleanup_but_raises_plugin_error(self, unused_mock_get_utility): + self.mock_client.add_txt_record.return_value = FAKE_RECORD + self.mock_client.del_txt_record.side_effect = mock.MagicMock( + side_effect=PluginError() + ) # _attempt_cleanup | pylint: disable=protected-access self.auth.perform([self.achall]) self.auth._attempt_cleanup = True - self.auth.cleanup([self.achall]) - self.mock_client.delete_record_by_name.assert_called_with(DOMAIN, '_acme-challenge.' + DOMAIN + '.') + self.assertRaises(PluginError, self.auth.cleanup, [self.achall]) + self.mock_client.del_txt_record.assert_called_with( + DOMAIN, "_acme-challenge." + DOMAIN + ".", mock.ANY + ) -if __name__ == '__main__': +if __name__ == "__main__": unittest.main() # pragma: no cover diff --git a/certbot_dns_hetzner/fakes.py b/certbot_dns_hetzner/fakes.py index e22f1fa..e67f258 100644 --- a/certbot_dns_hetzner/fakes.py +++ b/certbot_dns_hetzner/fakes.py @@ -2,17 +2,17 @@ Fakes needed for tests """ -FAKE_API_TOKEN = 'XXXXXXXXXXXXXXXXXXXxxx' +FAKE_API_TOKEN = "XXXXXXXXXXXXXXXXXXXxxx" FAKE_RECORD = { "record": { - 'id': "123Fake", + "id": "123Fake", } } -FAKE_DOMAIN = 'some.domain' -FAKE_ZONE_ID = 'xyz' -FAKE_RECORD_ID = 'zzz' -FAKE_RECORD_NAME = 'thisisarecordname' +FAKE_DOMAIN = "some.domain" +FAKE_ZONE_ID = "xyz" +FAKE_RECORD_ID = "zzz" +FAKE_RECORD_NAME = "thisisarecordname" FAKE_RECORD_RESPONSE = { "record": { @@ -33,7 +33,7 @@ FAKE_RECORDS_RESPONSE_WITHOUT_RECORD = { "records": [ { - "id": 'nottheoneuwant', + "id": "nottheoneuwant", "name": "string", } ] diff --git a/certbot_dns_hetzner/hetzner_client.py b/certbot_dns_hetzner/hetzner_client.py deleted file mode 100644 index 9f7e5ea..0000000 --- a/certbot_dns_hetzner/hetzner_client.py +++ /dev/null @@ -1,196 +0,0 @@ -""" -A Hetzner helper class to wrap the API relevant for the functionality in this plugin -""" -import json -import requests - -from certbot.plugins import dns_common - -HETZNER_API_ENDPOINT = 'https://dns.hetzner.com/api/v1' - - -class _HetznerException(Exception): - pass - - -class _ZoneNotFoundException(_HetznerException): - def __init__(self, domain_name, *args): - super(_ZoneNotFoundException, self).__init__('Zone {0} not found in Hetzner account'.format(domain_name), *args) - self.domain_name = domain_name - - -class _MalformedResponseException(_HetznerException): - def __init__(self, cause, *args): - super(_MalformedResponseException, self).__init__( - 'Received an unexpected response from Hetzner API:\n{0}'.format(cause), *args) - self.cause = cause - - -class _RecordNotFoundException(_HetznerException): - def __init__(self, record_name, *args): - super(_RecordNotFoundException, self).__init__('Record with name {0} not found'.format(record_name), *args) - self.record_name = record_name - - -class _NotAuthorizedException(_HetznerException): - def __init__(self, *args): - super(_NotAuthorizedException, self).__init__('Malformed authorization or invalid API token', *args) - - -class _UnprocessableEntityException(_HetznerException): - def __init__(self, record_data, *args): - super(_UnprocessableEntityException, self).__init__('Unprocessable entity in record {0}'.format(record_data), - *args) - self.record_data = record_data - - -class _HetznerClient: - """ - A little helper class for operations on the Hetzner DNS API - """ - - def __init__(self, token): - """ - Initialize client by providing a Hetzner DNS API token - :param token: Hetzner DNS API Token retrieved from: https://dns.hetzner.com/settings/api-token - """ - self.token = token - - @property - def _headers(self): - return { - "Content-Type": "application/json", - "X-Consumer-Username": "", - "Auth-API-Token": self.token, - } - - def add_record(self, domain, record_type, name, value, ttl): # pylint: disable=too-many-arguments - """ - API call to add record to zone matching ``domain`` to your Hetzner Account, while specifying ``record_type``, - ``name``, ``value`` and ``ttl`` - :param domain: Domain to determine zone where record should be added - :param record_type: A valid DNS record type - :param name: Full record name - :param value: Record value - :param ttl: Time to live - :raises ._MalformedResponseException: If the response is missing expected values or is invalid JSON - :raises ._ZoneNotFoundException: If no zone with the SLD and TLD of ``domain`` is found in your Hetzner account - :raises ._NotAuthorizedException: If Hetzner does not accept the authorization credentials - :raises ._UnprocessableEntityException: If the request is valid but still cannot be processed. e.g. - if it's committed to the wrong zone - :raises requests.exceptions.ConnectionError: If the API request fails - """ - zone_id = self._get_zone_id_by_domain(domain) - record_data = json.dumps({ - "value": value, - "ttl": ttl, - "type": record_type, - "name": name, - "zone_id": zone_id - }) - create_record_response = requests.post( - url="{0}/records".format(HETZNER_API_ENDPOINT), - headers=self._headers, - data=record_data - ) - if create_record_response.status_code == 401: - raise _NotAuthorizedException() - if create_record_response.status_code == 422: - raise _UnprocessableEntityException(record_data) - try: - return create_record_response.json() - except (ValueError, UnicodeDecodeError) as exception: - raise _MalformedResponseException(exception) - - def delete_record_by_name(self, domain, record_name): - """ - Searches for a zone matching ``domain``, if found find and delete a record matching ``record_name` - Deletes record with ``record_id`` from your Hetzner Account - :param domain: Domain of zone in which the record should be found - :param record_name: ID of record to be deleted - :raises requests.exceptions.ConnectionError: If the API request fails - :raises ._MalformedResponseException: If the API response is not 200 - :raises ._ZoneNotFoundException: If no zone is found matching ``domain`` - :raises ._RecordNotFoundException: If no record is found matching ``record_name`` - :raises ._NotAuthorizedException: If Hetzner does not accept the authorization credentials - """ - zone_id = self._get_zone_id_by_domain(domain) - record_id = self._get_record_id_by_name(zone_id, record_name) - self.delete_record(record_id) - - def delete_record(self, record_id): - """ - Deletes record with ``record_id`` from your Hetzner Account - :param record_id: ID of record to be deleted - :raises requests.exceptions.ConnectionError: If the API request fails - :raises ._MalformedResponseException: If the API response is not 200 - :raises ._NotAuthorizedException: If Hetzner does not accept the authorization credentials - """ - response = requests.delete( - url="{0}/records/{1}".format(HETZNER_API_ENDPOINT, record_id), - headers=self._headers - ) - if response.status_code == 401: - raise _NotAuthorizedException() - if response.status_code != 200: - raise _MalformedResponseException('Status code not 200') - - def _get_record_id_by_name(self, zone_id, record_name): - """ - :param zone_id: ID of dns zone where the record should be searched - :param record_name: Name of the record that is searched - :return: The ID of the record with name ``record_name`` if found - :raises ._MalformedResponseException: If the response is missing expected values or is invalid JSON - :raises requests.exceptions.ConnectionError: If the API request fails - :raises ._NotAuthorizedException: If Hetzner does not accept the authorization credentials - :rtype: str - """ - records_response = requests.get( - url="{0}/records".format(HETZNER_API_ENDPOINT), - params={ - 'zone_id': zone_id, - }, - headers=self._headers - ) - if records_response.status_code == 401: - raise _NotAuthorizedException() - try: - records = records_response.json()['records'] - for record in records: - if record['name'] == record_name: - return record['id'] - except (ValueError, UnicodeDecodeError, KeyError) as exception: - raise _MalformedResponseException(exception) - raise _RecordNotFoundException(record_name) - - def _get_zone_id_by_domain(self, domain): - """ - Requests all dns zones from your Hetzner account and searches for a specific one to determine the ID of it - :param domain: Name of dns zone where the record should be searched - :return: The ID of the zone that is SLD and TLD of ``domain`` - if found - :raises ._MalformedResponseException: If the response is missing expected values or is invalid JSON - :raises ._ZoneNotFoundException: If no zone with the SLD and TLD of ``domain`` is found in your Hetzner account - :raises ._NotAuthorizedException: If Hetzner does not accept the authorization credentials - :raises requests.exceptions.ConnectionError: If the API request fails - :rtype: str - """ - domain_name_guesses = dns_common.base_domain_name_guesses(domain) - zones_response = requests.get( - url="{0}/zones".format(HETZNER_API_ENDPOINT), - params={ - 'name': domain - }, - headers=self._headers, - ) - if zones_response.status_code == 401: - raise _NotAuthorizedException() - try: - zones = zones_response.json()['zones'] - # Find the most specific domain listed in the available zones - for guess in domain_name_guesses: - for zone in zones: - if zone['name'] == guess: - return zone['id'] - except (KeyError, UnicodeDecodeError, ValueError) as exception: - raise _MalformedResponseException(exception) - raise _ZoneNotFoundException(domain) diff --git a/certbot_dns_hetzner/hetzner_client_test.py b/certbot_dns_hetzner/hetzner_client_test.py deleted file mode 100644 index b0f55fa..0000000 --- a/certbot_dns_hetzner/hetzner_client_test.py +++ /dev/null @@ -1,140 +0,0 @@ -# pylint: disable=W0212 -""" -Test suite for _HetznerClient -""" -import unittest -import requests - -import requests_mock - -from certbot_dns_hetzner.fakes import FAKE_API_TOKEN, FAKE_RECORD_RESPONSE, FAKE_DOMAIN, \ - FAKE_ZONES_RESPONSE_WITH_DOMAIN, FAKE_ZONES_RESPONSE_WITHOUT_DOMAIN, FAKE_RECORD_ID, FAKE_ZONE_ID, \ - FAKE_RECORDS_RESPONSE_WITH_RECORD, FAKE_RECORD_NAME, FAKE_RECORDS_RESPONSE_WITHOUT_RECORD -from certbot_dns_hetzner.hetzner_client import HETZNER_API_ENDPOINT, _ZoneNotFoundException, \ - _MalformedResponseException, _NotAuthorizedException, _RecordNotFoundException - - -class HetznerClientTest(unittest.TestCase): - record_name = 'foo' - record_content = 'bar' - record_ttl = 42 - - def setUp(self): - from certbot_dns_hetzner.dns_hetzner import _HetznerClient # pylint: disable=import-outside-toplevel - self.client = _HetznerClient(FAKE_API_TOKEN) - - def test_get_zone_by_name(self): - with requests_mock.Mocker() as mock: - mock.get('{0}/zones'.format(HETZNER_API_ENDPOINT), status_code=200, json=FAKE_ZONES_RESPONSE_WITH_DOMAIN) - zone_id = self.client._get_zone_id_by_domain(FAKE_DOMAIN) - self.assertEqual(zone_id, FAKE_ZONE_ID) - - def test_get_zone_by_name_but_zone_response_is_garbage(self): - with requests_mock.Mocker() as mock: - mock.get('{0}/zones'.format(HETZNER_API_ENDPOINT), status_code=200, text='garbage') - self.assertRaises( - _MalformedResponseException, - self.client._get_zone_id_by_domain, FAKE_DOMAIN - ) - - def test_add_record(self): - with requests_mock.Mocker() as mock: - mock.get('{0}/zones'.format(HETZNER_API_ENDPOINT), status_code=200, json=FAKE_ZONES_RESPONSE_WITH_DOMAIN) - mock.post('{0}/records'.format(HETZNER_API_ENDPOINT), status_code=200, json=FAKE_RECORD_RESPONSE) - response = self.client.add_record(FAKE_DOMAIN, "TXT", "somename", "somevalue", 42) - self.assertEqual(response, FAKE_RECORD_RESPONSE) - - def test_add_record_but_zone_is_not_in_account(self): - with requests_mock.Mocker() as mock: - mock.get('{0}/zones'.format(HETZNER_API_ENDPOINT), status_code=200, json=FAKE_ZONES_RESPONSE_WITHOUT_DOMAIN) - mock.post('{0}/records'.format(HETZNER_API_ENDPOINT), status_code=200, json=FAKE_RECORD_RESPONSE) - self.assertRaises( - _ZoneNotFoundException, - self.client.add_record, FAKE_DOMAIN, "TXT", "somename", "somevalue", 42 - ) - - def test_add_record_but_record_creation_not_200(self): - with requests_mock.Mocker() as mock: - mock.get('{0}/zones'.format(HETZNER_API_ENDPOINT), status_code=200, json=FAKE_ZONES_RESPONSE_WITH_DOMAIN) - mock.post('{0}/records'.format(HETZNER_API_ENDPOINT), status_code=406) - self.assertRaises( - _MalformedResponseException, - self.client.add_record, FAKE_DOMAIN, "TXT", "somename", "somevalue", 42 - ) - - def test_add_record_but_record_but_unauthorized(self): - with requests_mock.Mocker() as mock: - mock.get('{0}/zones'.format(HETZNER_API_ENDPOINT), status_code=401) - self.assertRaises( - _NotAuthorizedException, - self.client.add_record, FAKE_DOMAIN, "TXT", "somename", "somevalue", 42 - ) - - def test_add_record_but_zone_listing_is_401(self): - with requests_mock.Mocker() as mock: - mock.get('{0}/zones'.format(HETZNER_API_ENDPOINT), status_code=401) - mock.post('{0}/records'.format(HETZNER_API_ENDPOINT), status_code=200) - self.assertRaises( - _NotAuthorizedException, - self.client.add_record, FAKE_DOMAIN, "TXT", "somename", "somevalue", 42 - ) - - def test_add_record_but_zone_listing_times_out(self): - with requests_mock.Mocker() as mock: - mock.get('{0}/zones'.format(HETZNER_API_ENDPOINT), exc=requests.ConnectTimeout) - mock.post('{0}/records'.format(HETZNER_API_ENDPOINT), status_code=200) - self.assertRaises( - requests.ConnectionError, - self.client.add_record, FAKE_DOMAIN, "TXT", "somename", "somevalue", 42 - ) - - def test_delete_record(self): - with requests_mock.Mocker() as mock: - mock.delete('{0}/records/{1}'.format(HETZNER_API_ENDPOINT, FAKE_RECORD_ID), status_code=200) - self.client.delete_record(FAKE_RECORD_ID) - - def test_delete_but_authorization_fails(self): - with requests_mock.Mocker() as mock: - mock.delete('{0}/records/{1}'.format(HETZNER_API_ENDPOINT, FAKE_RECORD_ID), status_code=401) - self.assertRaises( - _NotAuthorizedException, - self.client.delete_record, FAKE_RECORD_ID - ) - - def test_delete_record_but_deletion_is_404(self): - with requests_mock.Mocker() as mock: - mock.delete('{0}/records/{1}'.format(HETZNER_API_ENDPOINT, FAKE_RECORD_ID), status_code=404) - self.assertRaises( - _MalformedResponseException, - self.client.delete_record, FAKE_RECORD_ID - ) - - def test_delete_record_by_name_and_found(self): - with requests_mock.Mocker() as mock: - mock.get('{0}/zones'.format(HETZNER_API_ENDPOINT), status_code=200, json=FAKE_ZONES_RESPONSE_WITH_DOMAIN) - mock.get('{0}/records?zone_id={1}'.format( - HETZNER_API_ENDPOINT, - FAKE_ZONE_ID - ), status_code=200, json=FAKE_RECORDS_RESPONSE_WITH_RECORD) - mock.delete('{0}/records/{1}'.format(HETZNER_API_ENDPOINT, FAKE_RECORD_ID), status_code=200) - self.client.delete_record_by_name(FAKE_DOMAIN, FAKE_RECORD_NAME) - - def test_delete_record_by_name_but_its_not_found(self): - with requests_mock.Mocker() as mock: - mock.get('{0}/zones'.format(HETZNER_API_ENDPOINT), status_code=200, json=FAKE_ZONES_RESPONSE_WITH_DOMAIN) - mock.get('{0}/records?zone_id={1}'.format( - HETZNER_API_ENDPOINT, - FAKE_ZONE_ID - ), status_code=200, json=FAKE_RECORDS_RESPONSE_WITHOUT_RECORD) - self.assertRaises( - _RecordNotFoundException, - self.client.delete_record_by_name, FAKE_DOMAIN, FAKE_RECORD_NAME - ) - - def test_delete_record_by_name_but_zone_is_not_found(self): - with requests_mock.Mocker() as mock: - mock.get('{0}/zones'.format(HETZNER_API_ENDPOINT), status_code=200, json=FAKE_ZONES_RESPONSE_WITHOUT_DOMAIN) - self.assertRaises( - _ZoneNotFoundException, - self.client.delete_record_by_name, FAKE_DOMAIN, FAKE_RECORD_NAME - ) diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 0000000..84577ee --- /dev/null +++ b/codecov.yml @@ -0,0 +1,6 @@ +coverage: + status: + project: + default: + target: 80% + threshold: 10% \ No newline at end of file diff --git a/coverage.xml b/coverage.xml deleted file mode 100644 index 0e47145..0000000 --- a/coverage.xml +++ /dev/null @@ -1,141 +0,0 @@ - - - - - - /home/jonni/projects/ctrl.alt.coop/certbot-dns-hetzner - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/setup.py b/setup.py index 52a7b45..0488b1a 100644 --- a/setup.py +++ b/setup.py @@ -3,7 +3,7 @@ from setuptools import find_packages from setuptools import setup -version = '1.0.5' +version = '2.0.0' # This package relies on PyOpenSSL, requests, and six, however, it isn't # specified here to avoid masking the more specific request requirements in @@ -13,6 +13,7 @@ 'setuptools', 'requests', 'requests-mock', + 'dns-lexicon>=3.11.6', 'parsedatetime<=2.5;python_version<"3.0"' ] @@ -30,7 +31,7 @@ author="ctrl.alt.coop", author_email='kontakt@ctrl.alt.coop', license='Apache License 2.0', - python_requires='>=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*', + python_requires='>=3.7', classifiers=[ 'Development Status :: 5 - Production/Stable', 'Environment :: Plugins', @@ -38,12 +39,12 @@ 'License :: OSI Approved :: Apache Software License', 'Operating System :: POSIX :: Linux', 'Programming Language :: Python', - 'Programming Language :: Python :: 2', - 'Programming Language :: Python :: 2.7', 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.6', 'Programming Language :: Python :: 3.7', 'Programming Language :: Python :: 3.8', + 'Programming Language :: Python :: 3.9', + 'Programming Language :: Python :: 3.10', + 'Programming Language :: Python :: 3.11', 'Topic :: Internet :: WWW/HTTP', 'Topic :: Security', 'Topic :: System :: Installation/Setup', diff --git a/snap/hooks/post-refresh b/snap/hooks/post-refresh deleted file mode 100644 index bcb0dbb..0000000 --- a/snap/hooks/post-refresh +++ /dev/null @@ -1,21 +0,0 @@ -#!/bin/sh -e -# This file is generated by tools/generate_dnsplugins_postrefreshhook.sh and should not be edited manually. - -# get certbot version -if [ ! -f "$SNAP/certbot-shared/certbot-version.txt" ]; then - echo "No certbot version available; not doing version comparison check" >> "$SNAP_DATA/debuglog" - exit 0 -fi -cb_installed=$(cat $SNAP/certbot-shared/certbot-version.txt) - -# get required certbot version for plugin. certbot version must be at least the plugin's -# version. note that this is not the required version in setup.py, but the version number itself. -cb_required=$(grep -oP "version = '\K.*(?=')" $SNAP/setup.py) - - -$SNAP/bin/python3 -c "import sys; from packaging import version; sys.exit(1) if version.parse('$cb_installed') < version.parse('$cb_required') else sys.exit(0)" || exit_code=$? -if [ "$exit_code" -eq 1 ]; then - echo "Certbot is version $cb_installed but needs to be at least $cb_required before" \ - "this plugin can be updated; will try again on next refresh." - exit 1 -fi diff --git a/snap/snapcraft.yaml b/snap/snapcraft.yaml deleted file mode 100644 index cb2a978..0000000 --- a/snap/snapcraft.yaml +++ /dev/null @@ -1,41 +0,0 @@ -# This file is generated by tools/generate_dnsplugins_snapcraft.sh and should not be edited manually. -name: certbot-dns-hetzner -summary: Hetzner DNS Authenticator plugin for Certbot -description: Hetzner DNS Authenticator plugin for Certbot -confinement: strict -grade: devel -base: core20 -adopt-info: certbot-dns-hetzner - -parts: - certbot-dns-hetzner: - plugin: python - source: . - constraints: [$SNAPCRAFT_PART_SRC/snap-constraints.txt] - override-pull: | - snapcraftctl pull - snapcraftctl set-version `grep ^version $SNAPCRAFT_PART_SRC/setup.py | cut -f2 -d= | tr -d "'[:space:]"` - build-environment: - - SNAP_BUILD: "True" - # To build cryptography and cffi if needed - build-packages: [gcc, libffi-dev, libssl-dev, python3-dev] - certbot-metadata: - plugin: dump - source: . - stage: [setup.py, certbot-shared] - override-pull: | - snapcraftctl pull - mkdir -p $SNAPCRAFT_PART_SRC/certbot-shared - -slots: - certbot: - interface: content - content: certbot-1 - read: - - $SNAP/lib/python3.8/site-packages - -plugs: - certbot-metadata: - interface: content - content: metadata-1 - target: $SNAP/certbot-shared diff --git a/tox.ini b/tox.ini index d6e38b4..7a1bbd4 100644 --- a/tox.ini +++ b/tox.ini @@ -1,13 +1,14 @@ # content of: tox.ini , put in same dir as setup.py [tox] -envlist = py27,py36,py37,py38 +envlist = py37,py38,py39,py310,py311 [gh-actions] python = - 2.7: py27 - 3.6: py36 3.7: py37 3.8: py38, mypy + 3.9: py39, mypy + 3.10: py310, mypy + 3.11: py311, mypy [testenv] deps = pytest