From 732a3ac962ce5d1143fd0a962777301c1eef093c Mon Sep 17 00:00:00 2001 From: Adrien Ferrand Date: Tue, 26 Sep 2023 00:15:04 +0200 Subject: [PATCH] Refactor Lexicon-based DNS plugins (#9746) * Refactor Lexicon-based DNS plugins and upgrade minimal version of Lexicon * Relax filterwarning to comply with envs where boto3 is not installed * Update pinned dependencies * Use our previous method to deprecate part of modules * Safe import internally * Add changelog Co-authored-by: Brad Warren --- .../_internal/dns_dnsimple.py | 51 +---- .../_internal/tests/dns_dnsimple_test.py | 25 +-- certbot-dns-dnsimple/setup.py | 2 +- .../_internal/dns_dnsmadeeasy.py | 61 ++---- .../_internal/tests/dns_dnsmadeeasy_test.py | 24 +-- certbot-dns-dnsmadeeasy/setup.py | 2 +- .../_internal/dns_gehirn.py | 63 ++---- .../_internal/tests/dns_gehirn_test.py | 23 +- certbot-dns-gehirn/setup.py | 2 +- .../_internal/dns_linode.py | 75 ++----- .../_internal/tests/dns_linode_test.py | 68 ++---- certbot-dns-linode/setup.py | 2 +- .../_internal/dns_luadns.py | 58 +---- .../_internal/tests/dns_luadns_test.py | 23 +- certbot-dns-luadns/setup.py | 2 +- .../certbot_dns_nsone/_internal/dns_nsone.py | 54 +---- .../_internal/tests/dns_nsone_test.py | 24 +-- certbot-dns-nsone/setup.py | 2 +- .../certbot_dns_ovh/_internal/dns_ovh.py | 75 ++----- .../_internal/tests/dns_ovh_test.py | 30 +-- certbot-dns-ovh/setup.py | 2 +- .../_internal/dns_sakuracloud.py | 61 +----- .../_internal/tests/dns_sakuracloud_test.py | 26 +-- certbot-dns-sakuracloud/setup.py | 2 +- certbot/CHANGELOG.md | 9 + .../tests/plugins/dns_common_lexicon_test.py | 29 --- certbot/certbot/plugins/dns_common_lexicon.py | 165 ++++++++++++++- certbot/certbot/plugins/dns_test_common.py | 6 + .../plugins/dns_test_common_lexicon.py | 199 +++++++++++++++++- tools/oldest_constraints.txt | 12 +- tools/pinning/current/pyproject.toml | 5 - tools/pinning/oldest/pyproject.toml | 2 +- tools/requirements.txt | 27 +-- 33 files changed, 542 insertions(+), 669 deletions(-) delete mode 100644 certbot/certbot/_internal/tests/plugins/dns_common_lexicon_test.py diff --git a/certbot-dns-dnsimple/certbot_dns_dnsimple/_internal/dns_dnsimple.py b/certbot-dns-dnsimple/certbot_dns_dnsimple/_internal/dns_dnsimple.py index c361e9b07..3d1017f0b 100644 --- a/certbot-dns-dnsimple/certbot_dns_dnsimple/_internal/dns_dnsimple.py +++ b/certbot-dns-dnsimple/certbot_dns_dnsimple/_internal/dns_dnsimple.py @@ -2,34 +2,30 @@ import logging from typing import Any from typing import Callable -from typing import cast -from typing import Optional -from lexicon.providers import dnsimple from requests import HTTPError from certbot import errors -from certbot.plugins import dns_common from certbot.plugins import dns_common_lexicon -from certbot.plugins.dns_common import CredentialsConfiguration logger = logging.getLogger(__name__) ACCOUNT_URL = 'https://dnsimple.com/user' -class Authenticator(dns_common.DNSAuthenticator): +class Authenticator(dns_common_lexicon.LexiconDNSAuthenticator): """DNS Authenticator for DNSimple This Authenticator uses the DNSimple v2 API to fulfill a dns-01 challenge. """ description = 'Obtain certificates using a DNS TXT record (if you are using DNSimple for DNS).' - ttl = 60 def __init__(self, *args: Any, **kwargs: Any) -> None: super().__init__(*args, **kwargs) - self.credentials: Optional[CredentialsConfiguration] = None + self._add_provider_option('token', + f'User access token for DNSimple v2 API. (See {ACCOUNT_URL}.)', + 'auth_token') @classmethod def add_parser_arguments(cls, add: Callable[..., None], @@ -41,42 +37,9 @@ class Authenticator(dns_common.DNSAuthenticator): return 'This plugin configures a DNS TXT record to respond to a dns-01 challenge using ' + \ 'the DNSimple API.' - def _setup_credentials(self) -> None: - self.credentials = self._configure_credentials( - 'credentials', - 'DNSimple credentials INI file', - { - 'token': 'User access token for DNSimple v2 API. (See {0}.)'.format(ACCOUNT_URL) - } - ) - - def _perform(self, domain: str, validation_name: str, validation: str) -> None: - self._get_dnsimple_client().add_txt_record(domain, validation_name, validation) - - def _cleanup(self, domain: str, validation_name: str, validation: str) -> None: - self._get_dnsimple_client().del_txt_record(domain, validation_name, validation) - - def _get_dnsimple_client(self) -> "_DNSimpleLexiconClient": - if not self.credentials: # pragma: no cover - raise errors.Error("Plugin has not been prepared.") - return _DNSimpleLexiconClient(cast(str, self.credentials.conf('token')), self.ttl) - - -class _DNSimpleLexiconClient(dns_common_lexicon.LexiconClient): - """ - Encapsulates all communication with the DNSimple via Lexicon. - """ - - def __init__(self, token: str, ttl: int) -> None: - super().__init__() - - config = dns_common_lexicon.build_lexicon_config('dnssimple', { - 'ttl': ttl, - }, { - 'auth_token': token, - }) - - self.provider = dnsimple.Provider(config) + @property + def _provider_name(self) -> str: + return 'dnssimple' def _handle_http_error(self, e: HTTPError, domain_name: str) -> errors.PluginError: hint = None diff --git a/certbot-dns-dnsimple/certbot_dns_dnsimple/_internal/tests/dns_dnsimple_test.py b/certbot-dns-dnsimple/certbot_dns_dnsimple/_internal/tests/dns_dnsimple_test.py index 31a91be0f..ff299ad49 100644 --- a/certbot-dns-dnsimple/certbot_dns_dnsimple/_internal/tests/dns_dnsimple_test.py +++ b/certbot-dns-dnsimple/certbot_dns_dnsimple/_internal/tests/dns_dnsimple_test.py @@ -1,8 +1,6 @@ """Tests for certbot_dns_dnsimple._internal.dns_dnsimple.""" - -import sys -import unittest from unittest import mock +import sys import pytest from requests.exceptions import HTTPError @@ -16,7 +14,9 @@ TOKEN = 'foo' class AuthenticatorTest(test_util.TempDirTestCase, - dns_test_common_lexicon.BaseLexiconAuthenticatorTest): + dns_test_common_lexicon.BaseLexiconDNSAuthenticatorTest): + + LOGIN_ERROR = HTTPError('401 Client Error: Unauthorized for url: ...') def setUp(self): super().setUp() @@ -31,23 +31,6 @@ class AuthenticatorTest(test_util.TempDirTestCase, self.auth = Authenticator(self.config, "dnsimple") - self.mock_client = mock.MagicMock() - # _get_dnsimple_client | pylint: disable=protected-access - self.auth._get_dnsimple_client = mock.MagicMock(return_value=self.mock_client) - - -class DNSimpleLexiconClientTest(unittest.TestCase, dns_test_common_lexicon.BaseLexiconClientTest): - - LOGIN_ERROR = HTTPError('401 Client Error: Unauthorized for url: ...') - - def setUp(self): - from certbot_dns_dnsimple._internal.dns_dnsimple import _DNSimpleLexiconClient - - self.client = _DNSimpleLexiconClient(TOKEN, 0) - - self.provider_mock = mock.MagicMock() - self.client.provider = self.provider_mock - if __name__ == "__main__": sys.exit(pytest.main(sys.argv[1:] + [__file__])) # pragma: no cover diff --git a/certbot-dns-dnsimple/setup.py b/certbot-dns-dnsimple/setup.py index 7a988382d..9cc24a494 100644 --- a/certbot-dns-dnsimple/setup.py +++ b/certbot-dns-dnsimple/setup.py @@ -9,7 +9,7 @@ version = '2.7.0.dev0' install_requires = [ # This version of lexicon is required to address the problem described in # https://github.com/AnalogJ/lexicon/issues/387. - 'dns-lexicon>=3.2.1', + 'dns-lexicon>=3.14.1', 'setuptools>=41.6.0', ] diff --git a/certbot-dns-dnsmadeeasy/certbot_dns_dnsmadeeasy/_internal/dns_dnsmadeeasy.py b/certbot-dns-dnsmadeeasy/certbot_dns_dnsmadeeasy/_internal/dns_dnsmadeeasy.py index b426d43b2..24733ce19 100644 --- a/certbot-dns-dnsmadeeasy/certbot_dns_dnsmadeeasy/_internal/dns_dnsmadeeasy.py +++ b/certbot-dns-dnsmadeeasy/certbot_dns_dnsmadeeasy/_internal/dns_dnsmadeeasy.py @@ -2,23 +2,19 @@ import logging from typing import Any from typing import Callable -from typing import cast from typing import Optional -from lexicon.providers import dnsmadeeasy from requests import HTTPError from certbot import errors -from certbot.plugins import dns_common from certbot.plugins import dns_common_lexicon -from certbot.plugins.dns_common import CredentialsConfiguration logger = logging.getLogger(__name__) ACCOUNT_URL = 'https://cp.dnsmadeeasy.com/account/info' -class Authenticator(dns_common.DNSAuthenticator): +class Authenticator(dns_common_lexicon.LexiconDNSAuthenticator): """DNS Authenticator for DNS Made Easy This Authenticator uses the DNS Made Easy API to fulfill a dns-01 challenge. @@ -26,11 +22,17 @@ class Authenticator(dns_common.DNSAuthenticator): description = ('Obtain certificates using a DNS TXT record (if you are using DNS Made Easy for ' 'DNS).') - ttl = 60 def __init__(self, *args: Any, **kwargs: Any) -> None: super().__init__(*args, **kwargs) - self.credentials: Optional[CredentialsConfiguration] = None + self._add_provider_option('api-key', + 'API key for DNS Made Easy account, ' + f'obtained from {ACCOUNT_URL}', + 'auth_username') + self._add_provider_option('secret-key', + 'Secret key for DNS Made Easy account, ' + f'obtained from {ACCOUNT_URL}', + 'auth_token') @classmethod def add_parser_arguments(cls, add: Callable[..., None], @@ -42,48 +44,9 @@ class Authenticator(dns_common.DNSAuthenticator): return 'This plugin configures a DNS TXT record to respond to a dns-01 challenge using ' + \ 'the DNS Made Easy API.' - def _setup_credentials(self) -> None: - self.credentials = self._configure_credentials( - 'credentials', - 'DNS Made Easy credentials INI file', - { - 'api-key': 'API key for DNS Made Easy account, obtained from {0}' - .format(ACCOUNT_URL), - 'secret-key': 'Secret key for DNS Made Easy account, obtained from {0}' - .format(ACCOUNT_URL) - } - ) - - def _perform(self, domain: str, validation_name: str, validation: str) -> None: - self._get_dnsmadeeasy_client().add_txt_record(domain, validation_name, validation) - - def _cleanup(self, domain: str, validation_name: str, validation: str) -> None: - self._get_dnsmadeeasy_client().del_txt_record(domain, validation_name, validation) - - def _get_dnsmadeeasy_client(self) -> "_DNSMadeEasyLexiconClient": - if not self.credentials: # pragma: no cover - raise errors.Error("Plugin has not been prepared.") - return _DNSMadeEasyLexiconClient(cast(str, self.credentials.conf('api-key')), - cast(str, self.credentials.conf('secret-key')), - self.ttl) - - -class _DNSMadeEasyLexiconClient(dns_common_lexicon.LexiconClient): - """ - Encapsulates all communication with the DNS Made Easy via Lexicon. - """ - - def __init__(self, api_key: str, secret_key: str, ttl: int) -> None: - super().__init__() - - config = dns_common_lexicon.build_lexicon_config('dnsmadeeasy', { - 'ttl': ttl, - }, { - 'auth_username': api_key, - 'auth_token': secret_key, - }) - - self.provider = dnsmadeeasy.Provider(config) + @property + def _provider_name(self) -> str: + return 'dnsmadeeasy' def _handle_http_error(self, e: HTTPError, domain_name: str) -> Optional[errors.PluginError]: if domain_name in str(e) and str(e).startswith('404 Client Error: Not Found for url:'): diff --git a/certbot-dns-dnsmadeeasy/certbot_dns_dnsmadeeasy/_internal/tests/dns_dnsmadeeasy_test.py b/certbot-dns-dnsmadeeasy/certbot_dns_dnsmadeeasy/_internal/tests/dns_dnsmadeeasy_test.py index 2295e83cf..286e65ed9 100644 --- a/certbot-dns-dnsmadeeasy/certbot_dns_dnsmadeeasy/_internal/tests/dns_dnsmadeeasy_test.py +++ b/certbot-dns-dnsmadeeasy/certbot_dns_dnsmadeeasy/_internal/tests/dns_dnsmadeeasy_test.py @@ -1,7 +1,6 @@ """Tests for certbot_dns_dnsmadeeasy._internal.dns_dnsmadeeasy.""" import sys -import unittest from unittest import mock import pytest @@ -18,7 +17,10 @@ SECRET_KEY = 'bar' class AuthenticatorTest(test_util.TempDirTestCase, - dns_test_common_lexicon.BaseLexiconAuthenticatorTest): + dns_test_common_lexicon.BaseLexiconDNSAuthenticatorTest): + + DOMAIN_NOT_FOUND = HTTPError(f'404 Client Error: Not Found for url: {DOMAIN}.') + LOGIN_ERROR = HTTPError(f'403 Client Error: Forbidden for url: {DOMAIN}.') def setUp(self): super().setUp() @@ -35,24 +37,6 @@ class AuthenticatorTest(test_util.TempDirTestCase, self.auth = Authenticator(self.config, "dnsmadeeasy") - self.mock_client = mock.MagicMock() - # _get_dnsmadeeasy_client | pylint: disable=protected-access - self.auth._get_dnsmadeeasy_client = mock.MagicMock(return_value=self.mock_client) - - -class DNSMadeEasyLexiconClientTest(unittest.TestCase, - dns_test_common_lexicon.BaseLexiconClientTest): - DOMAIN_NOT_FOUND = HTTPError('404 Client Error: Not Found for url: {0}.'.format(DOMAIN)) - LOGIN_ERROR = HTTPError('403 Client Error: Forbidden for url: {0}.'.format(DOMAIN)) - - def setUp(self): - from certbot_dns_dnsmadeeasy._internal.dns_dnsmadeeasy import _DNSMadeEasyLexiconClient - - self.client = _DNSMadeEasyLexiconClient(API_KEY, SECRET_KEY, 0) - - self.provider_mock = mock.MagicMock() - self.client.provider = self.provider_mock - if __name__ == "__main__": sys.exit(pytest.main(sys.argv[1:] + [__file__])) # pragma: no cover diff --git a/certbot-dns-dnsmadeeasy/setup.py b/certbot-dns-dnsmadeeasy/setup.py index ced2a9602..d159d548d 100644 --- a/certbot-dns-dnsmadeeasy/setup.py +++ b/certbot-dns-dnsmadeeasy/setup.py @@ -7,7 +7,7 @@ from setuptools import setup version = '2.7.0.dev0' install_requires = [ - 'dns-lexicon>=3.2.1', + 'dns-lexicon>=3.14.1', 'setuptools>=41.6.0', ] diff --git a/certbot-dns-gehirn/certbot_dns_gehirn/_internal/dns_gehirn.py b/certbot-dns-gehirn/certbot_dns_gehirn/_internal/dns_gehirn.py index 60a13d965..8fd5ecd52 100644 --- a/certbot-dns-gehirn/certbot_dns_gehirn/_internal/dns_gehirn.py +++ b/certbot-dns-gehirn/certbot_dns_gehirn/_internal/dns_gehirn.py @@ -2,23 +2,19 @@ import logging from typing import Any from typing import Callable -from typing import cast from typing import Optional -from lexicon.providers import gehirn from requests import HTTPError from certbot import errors -from certbot.plugins import dns_common from certbot.plugins import dns_common_lexicon -from certbot.plugins.dns_common import CredentialsConfiguration logger = logging.getLogger(__name__) DASHBOARD_URL = "https://gis.gehirn.jp/" -class Authenticator(dns_common.DNSAuthenticator): +class Authenticator(dns_common_lexicon.LexiconDNSAuthenticator): """DNS Authenticator for Gehirn Infrastructure Service DNS This Authenticator uses the Gehirn Infrastructure Service API to fulfill @@ -27,11 +23,17 @@ class Authenticator(dns_common.DNSAuthenticator): description = 'Obtain certificates using a DNS TXT record ' + \ '(if you are using Gehirn Infrastructure Service for DNS).' - ttl = 60 def __init__(self, *args: Any, **kwargs: Any) -> None: super().__init__(*args, **kwargs) - self.credentials: Optional[CredentialsConfiguration] = None + self._add_provider_option('api-token', + 'API token for Gehirn Infrastructure Service ' + f'API obtained from {DASHBOARD_URL}', + 'auth_token') + self._add_provider_option('api-secret', + 'API secret for Gehirn Infrastructure Service ' + f'API obtained from {DASHBOARD_URL}', + 'auth_secret') @classmethod def add_parser_arguments(cls, add: Callable[..., None], @@ -43,50 +45,9 @@ class Authenticator(dns_common.DNSAuthenticator): return 'This plugin configures a DNS TXT record to respond to a dns-01 challenge using ' + \ 'the Gehirn Infrastructure Service API.' - def _setup_credentials(self) -> None: - self.credentials = self._configure_credentials( - 'credentials', - 'Gehirn Infrastructure Service credentials file', - { - 'api-token': 'API token for Gehirn Infrastructure Service ' + \ - 'API obtained from {0}'.format(DASHBOARD_URL), - 'api-secret': 'API secret for Gehirn Infrastructure Service ' + \ - 'API obtained from {0}'.format(DASHBOARD_URL), - } - ) - - def _perform(self, domain: str, validation_name: str, validation: str) -> None: - self._get_gehirn_client().add_txt_record(domain, validation_name, validation) - - def _cleanup(self, domain: str, validation_name: str, validation: str) -> None: - self._get_gehirn_client().del_txt_record(domain, validation_name, validation) - - def _get_gehirn_client(self) -> "_GehirnLexiconClient": - if not self.credentials: # pragma: no cover - raise errors.Error("Plugin has not been prepared.") - return _GehirnLexiconClient( - cast(str, self.credentials.conf('api-token')), - cast(str, self.credentials.conf('api-secret')), - self.ttl - ) - - -class _GehirnLexiconClient(dns_common_lexicon.LexiconClient): - """ - Encapsulates all communication with the Gehirn Infrastructure Service via Lexicon. - """ - - def __init__(self, api_token: str, api_secret: str, ttl: int) -> None: - super().__init__() - - config = dns_common_lexicon.build_lexicon_config('gehirn', { - 'ttl': ttl, - }, { - 'auth_token': api_token, - 'auth_secret': api_secret, - }) - - self.provider = gehirn.Provider(config) + @property + def _provider_name(self) -> str: + return 'gehirn' def _handle_http_error(self, e: HTTPError, domain_name: str) -> Optional[errors.PluginError]: if domain_name in str(e) and (str(e).startswith('404 Client Error: Not Found for url:')): diff --git a/certbot-dns-gehirn/certbot_dns_gehirn/_internal/tests/dns_gehirn_test.py b/certbot-dns-gehirn/certbot_dns_gehirn/_internal/tests/dns_gehirn_test.py index b15e36cab..3d082049d 100644 --- a/certbot-dns-gehirn/certbot_dns_gehirn/_internal/tests/dns_gehirn_test.py +++ b/certbot-dns-gehirn/certbot_dns_gehirn/_internal/tests/dns_gehirn_test.py @@ -16,8 +16,12 @@ from certbot.tests import util as test_util API_TOKEN = '00000000-0000-0000-0000-000000000000' API_SECRET = 'MDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAw' + class AuthenticatorTest(test_util.TempDirTestCase, - dns_test_common_lexicon.BaseLexiconAuthenticatorTest): + dns_test_common_lexicon.BaseLexiconDNSAuthenticatorTest): + + DOMAIN_NOT_FOUND = HTTPError(f'404 Client Error: Not Found for url: {DOMAIN}.') + LOGIN_ERROR = HTTPError(f'401 Client Error: Unauthorized for url: {DOMAIN}.') def setUp(self): super().setUp() @@ -35,23 +39,6 @@ class AuthenticatorTest(test_util.TempDirTestCase, self.auth = Authenticator(self.config, "gehirn") - self.mock_client = mock.MagicMock() - # _get_gehirn_client | pylint: disable=protected-access - self.auth._get_gehirn_client = mock.MagicMock(return_value=self.mock_client) - - -class GehirnLexiconClientTest(unittest.TestCase, dns_test_common_lexicon.BaseLexiconClientTest): - DOMAIN_NOT_FOUND = HTTPError('404 Client Error: Not Found for url: {0}.'.format(DOMAIN)) - LOGIN_ERROR = HTTPError('401 Client Error: Unauthorized for url: {0}.'.format(DOMAIN)) - - def setUp(self): - from certbot_dns_gehirn._internal.dns_gehirn import _GehirnLexiconClient - - self.client = _GehirnLexiconClient(API_TOKEN, API_SECRET, 0) - - self.provider_mock = mock.MagicMock() - self.client.provider = self.provider_mock - if __name__ == "__main__": sys.exit(pytest.main(sys.argv[1:] + [__file__])) # pragma: no cover diff --git a/certbot-dns-gehirn/setup.py b/certbot-dns-gehirn/setup.py index df61affaf..6d6b213f7 100644 --- a/certbot-dns-gehirn/setup.py +++ b/certbot-dns-gehirn/setup.py @@ -7,7 +7,7 @@ from setuptools import setup version = '2.7.0.dev0' install_requires = [ - 'dns-lexicon>=3.2.1', + 'dns-lexicon>=3.14.1', 'setuptools>=41.6.0', ] diff --git a/certbot-dns-linode/certbot_dns_linode/_internal/dns_linode.py b/certbot-dns-linode/certbot_dns_linode/_internal/dns_linode.py index 2abc3a5c8..d9973dcc0 100644 --- a/certbot-dns-linode/certbot_dns_linode/_internal/dns_linode.py +++ b/certbot-dns-linode/certbot_dns_linode/_internal/dns_linode.py @@ -7,13 +7,8 @@ from typing import cast from typing import Optional from typing import Union -from lexicon.providers import linode -from lexicon.providers import linode4 - from certbot import errors -from certbot.plugins import dns_common from certbot.plugins import dns_common_lexicon -from certbot.plugins.dns_common import CredentialsConfiguration logger = logging.getLogger(__name__) @@ -21,7 +16,7 @@ API_KEY_URL = 'https://manager.linode.com/profile/api' API_KEY_URL_V4 = 'https://cloud.linode.com/profile/tokens' -class Authenticator(dns_common.DNSAuthenticator): +class Authenticator(dns_common_lexicon.LexiconDNSAuthenticator): """DNS Authenticator for Linode This Authenticator uses the Linode API to fulfill a dns-01 challenge. @@ -31,7 +26,10 @@ class Authenticator(dns_common.DNSAuthenticator): def __init__(self, *args: Any, **kwargs: Any) -> None: super().__init__(*args, **kwargs) - self.credentials: Optional[CredentialsConfiguration] = None + self._add_provider_option('key', + 'API key for Linode account, ' + f'obtained from {API_KEY_URL} or {API_KEY_URL_V4}', + 'auth_token') @classmethod def add_parser_arguments(cls, add: Callable[..., None], @@ -43,29 +41,13 @@ class Authenticator(dns_common.DNSAuthenticator): return 'This plugin configures a DNS TXT record to respond to a dns-01 challenge using ' + \ 'the Linode API.' - def _setup_credentials(self) -> None: - self.credentials = self._configure_credentials( - 'credentials', - 'Linode credentials INI file', - { - 'key': 'API key for Linode account, obtained from {0} or {1}' - .format(API_KEY_URL, API_KEY_URL_V4) - } - ) + @property + def _provider_name(self) -> str: + if not hasattr(self, '_credentials'): # pragma: no cover + self._setup_credentials() - def _perform(self, domain: str, validation_name: str, validation: str) -> None: - self._get_linode_client().add_txt_record(domain, validation_name, validation) - - def _cleanup(self, domain: str, validation_name: str, validation: str) -> None: - self._get_linode_client().del_txt_record(domain, validation_name, validation) - - def _get_linode_client(self) -> '_LinodeLexiconClient': - if not self.credentials: # pragma: no cover - raise errors.Error("Plugin has not been prepared.") - api_key = cast(str, self.credentials.conf('key')) - api_version: Optional[Union[str, int]] = self.credentials.conf('version') - if api_version == '': - api_version = None + api_key = cast(str, self._credentials.conf('key')) + api_version: Optional[Union[str, int]] = self._credentials.conf('version') if not api_version: api_version = 3 @@ -78,34 +60,19 @@ class Authenticator(dns_common.DNSAuthenticator): else: api_version = int(api_version) - return _LinodeLexiconClient(api_key, api_version) - - -class _LinodeLexiconClient(dns_common_lexicon.LexiconClient): - """ - Encapsulates all communication with the Linode API. - """ - - def __init__(self, api_key: str, api_version: int) -> None: - super().__init__() - - self.api_version = api_version - if api_version == 3: - config = dns_common_lexicon.build_lexicon_config('linode', {}, { - 'auth_token': api_key, - }) - - self.provider = linode.Provider(config) + return 'linode' elif api_version == 4: - config = dns_common_lexicon.build_lexicon_config('linode4', {}, { - 'auth_token': api_key, - }) + return 'linode4' - self.provider = linode4.Provider(config) - else: - raise errors.PluginError('Invalid api version specified: {0}. (Supported: 3, 4)' - .format(api_version)) + raise errors.PluginError(f'Invalid api version specified: {api_version}. (Supported: 3, 4)') + + def _setup_credentials(self) -> None: + self._credentials = self._configure_credentials( + key='credentials', + label='Credentials INI file for linode DNS authenticator', + required_variables={item[0]: item[1] for item in self._provider_options}, + ) def _handle_general_error(self, e: Exception, domain_name: str) -> Optional[errors.PluginError]: if not str(e).startswith('Domain not found'): diff --git a/certbot-dns-linode/certbot_dns_linode/_internal/tests/dns_linode_test.py b/certbot-dns-linode/certbot_dns_linode/_internal/tests/dns_linode_test.py index 28e3f5265..a46174966 100644 --- a/certbot-dns-linode/certbot_dns_linode/_internal/tests/dns_linode_test.py +++ b/certbot-dns-linode/certbot_dns_linode/_internal/tests/dns_linode_test.py @@ -19,7 +19,9 @@ TOKEN_V4 = '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef' class AuthenticatorTest(test_util.TempDirTestCase, - dns_test_common_lexicon.BaseLexiconAuthenticatorTest): + dns_test_common_lexicon.BaseLexiconDNSAuthenticatorTest): + + DOMAIN_NOT_FOUND = Exception('Domain not found') def setUp(self): super().setUp() @@ -32,10 +34,6 @@ class AuthenticatorTest(test_util.TempDirTestCase, self.auth = Authenticator(self.config, "linode") - self.mock_client = mock.MagicMock() - # _get_linode_client | pylint: disable=protected-access - self.auth._get_linode_client = mock.MagicMock(return_value=self.mock_client) - # pylint: disable=protected-access def test_api_version_3_detection(self): path = os.path.join(self.tempdir, 'file_3_auto.ini') @@ -44,9 +42,8 @@ class AuthenticatorTest(test_util.TempDirTestCase, config = mock.MagicMock(linode_credentials=path, linode_propagation_seconds=0) auth = Authenticator(config, "linode") - auth._setup_credentials() - client = auth._get_linode_client() - assert 3 == client.api_version + + assert auth._provider_name == "linode" # pylint: disable=protected-access def test_api_version_4_detection(self): @@ -56,9 +53,8 @@ class AuthenticatorTest(test_util.TempDirTestCase, config = mock.MagicMock(linode_credentials=path, linode_propagation_seconds=0) auth = Authenticator(config, "linode") - auth._setup_credentials() - client = auth._get_linode_client() - assert 4 == client.api_version + + assert auth._provider_name == "linode4" # pylint: disable=protected-access def test_api_version_3_detection_empty_version(self): @@ -68,9 +64,8 @@ class AuthenticatorTest(test_util.TempDirTestCase, config = mock.MagicMock(linode_credentials=path, linode_propagation_seconds=0) auth = Authenticator(config, "linode") - auth._setup_credentials() - client = auth._get_linode_client() - assert 3 == client.api_version + + assert auth._provider_name == "linode" # pylint: disable=protected-access def test_api_version_4_detection_empty_version(self): @@ -80,9 +75,8 @@ class AuthenticatorTest(test_util.TempDirTestCase, config = mock.MagicMock(linode_credentials=path, linode_propagation_seconds=0) auth = Authenticator(config, "linode") - auth._setup_credentials() - client = auth._get_linode_client() - assert 4 == client.api_version + + assert auth._provider_name == "linode4" # pylint: disable=protected-access def test_api_version_3_manual(self): @@ -92,9 +86,8 @@ class AuthenticatorTest(test_util.TempDirTestCase, config = mock.MagicMock(linode_credentials=path, linode_propagation_seconds=0) auth = Authenticator(config, "linode") - auth._setup_credentials() - client = auth._get_linode_client() - assert 3 == client.api_version + + assert auth._provider_name == "linode" # pylint: disable=protected-access def test_api_version_4_manual(self): @@ -104,9 +97,8 @@ class AuthenticatorTest(test_util.TempDirTestCase, config = mock.MagicMock(linode_credentials=path, linode_propagation_seconds=0) auth = Authenticator(config, "linode") - auth._setup_credentials() - client = auth._get_linode_client() - assert 4 == client.api_version + + assert auth._provider_name == "linode4" # pylint: disable=protected-access def test_api_version_error(self): @@ -116,35 +108,9 @@ class AuthenticatorTest(test_util.TempDirTestCase, config = mock.MagicMock(linode_credentials=path, linode_propagation_seconds=0) auth = Authenticator(config, "linode") - auth._setup_credentials() + with pytest.raises(errors.PluginError): - auth._get_linode_client() - - -class LinodeLexiconClientTest(unittest.TestCase, dns_test_common_lexicon.BaseLexiconClientTest): - - DOMAIN_NOT_FOUND = Exception('Domain not found') - - def setUp(self): - from certbot_dns_linode._internal.dns_linode import _LinodeLexiconClient - - self.client = _LinodeLexiconClient(TOKEN, 3) - - self.provider_mock = mock.MagicMock() - self.client.provider = self.provider_mock - - -class Linode4LexiconClientTest(unittest.TestCase, dns_test_common_lexicon.BaseLexiconClientTest): - - DOMAIN_NOT_FOUND = Exception('Domain not found') - - def setUp(self): - from certbot_dns_linode._internal.dns_linode import _LinodeLexiconClient - - self.client = _LinodeLexiconClient(TOKEN, 4) - - self.provider_mock = mock.MagicMock() - self.client.provider = self.provider_mock + assert auth._provider_name == "linode4" if __name__ == "__main__": diff --git a/certbot-dns-linode/setup.py b/certbot-dns-linode/setup.py index 7aff11fad..bd277a324 100644 --- a/certbot-dns-linode/setup.py +++ b/certbot-dns-linode/setup.py @@ -7,7 +7,7 @@ from setuptools import setup version = '2.7.0.dev0' install_requires = [ - 'dns-lexicon>=3.2.1', + 'dns-lexicon>=3.14.1', 'setuptools>=41.6.0', ] diff --git a/certbot-dns-luadns/certbot_dns_luadns/_internal/dns_luadns.py b/certbot-dns-luadns/certbot_dns_luadns/_internal/dns_luadns.py index 684113b62..91d5a801d 100644 --- a/certbot-dns-luadns/certbot_dns_luadns/_internal/dns_luadns.py +++ b/certbot-dns-luadns/certbot_dns_luadns/_internal/dns_luadns.py @@ -2,34 +2,33 @@ import logging from typing import Any from typing import Callable -from typing import cast -from typing import Optional -from lexicon.providers import luadns from requests import HTTPError from certbot import errors -from certbot.plugins import dns_common from certbot.plugins import dns_common_lexicon -from certbot.plugins.dns_common import CredentialsConfiguration logger = logging.getLogger(__name__) ACCOUNT_URL = 'https://api.luadns.com/settings' -class Authenticator(dns_common.DNSAuthenticator): +class Authenticator(dns_common_lexicon.LexiconDNSAuthenticator): """DNS Authenticator for LuaDNS This Authenticator uses the LuaDNS API to fulfill a dns-01 challenge. """ description = 'Obtain certificates using a DNS TXT record (if you are using LuaDNS for DNS).' - ttl = 60 def __init__(self, *args: Any, **kwargs: Any) -> None: super().__init__(*args, **kwargs) - self.credentials: Optional[CredentialsConfiguration] = None + self._add_provider_option('email', + 'email address associated with LuaDNS account', + 'auth_username') + self._add_provider_option('token', + f'API token for LuaDNS account, obtained from {ACCOUNT_URL}', + 'auth_token') @classmethod def add_parser_arguments(cls, add: Callable[..., None], @@ -41,46 +40,9 @@ class Authenticator(dns_common.DNSAuthenticator): return 'This plugin configures a DNS TXT record to respond to a dns-01 challenge using ' + \ 'the LuaDNS API.' - def _setup_credentials(self) -> None: - self.credentials = self._configure_credentials( - 'credentials', - 'LuaDNS credentials INI file', - { - 'email': 'email address associated with LuaDNS account', - 'token': 'API token for LuaDNS account, obtained from {0}'.format(ACCOUNT_URL) - } - ) - - def _perform(self, domain: str, validation_name: str, validation: str) -> None: - self._get_luadns_client().add_txt_record(domain, validation_name, validation) - - def _cleanup(self, domain: str, validation_name: str, validation: str) -> None: - self._get_luadns_client().del_txt_record(domain, validation_name, validation) - - def _get_luadns_client(self) -> "_LuaDNSLexiconClient": - if not self.credentials: # pragma: no cover - raise errors.Error("Plugin has not been prepared.") - return _LuaDNSLexiconClient(cast(str, self.credentials.conf('email')), - cast(str, self.credentials.conf('token')), - self.ttl) - - -class _LuaDNSLexiconClient(dns_common_lexicon.LexiconClient): - """ - Encapsulates all communication with the LuaDNS via Lexicon. - """ - - def __init__(self, email: str, token: str, ttl: int) -> None: - super().__init__() - - config = dns_common_lexicon.build_lexicon_config('luadns', { - 'ttl': ttl, - }, { - 'auth_username': email, - 'auth_token': token, - }) - - self.provider = luadns.Provider(config) + @property + def _provider_name(self) -> str: + return 'luadns' def _handle_http_error(self, e: HTTPError, domain_name: str) -> errors.PluginError: hint = None diff --git a/certbot-dns-luadns/certbot_dns_luadns/_internal/tests/dns_luadns_test.py b/certbot-dns-luadns/certbot_dns_luadns/_internal/tests/dns_luadns_test.py index 2cade9f9a..0b424e860 100644 --- a/certbot-dns-luadns/certbot_dns_luadns/_internal/tests/dns_luadns_test.py +++ b/certbot-dns-luadns/certbot_dns_luadns/_internal/tests/dns_luadns_test.py @@ -1,7 +1,5 @@ """Tests for certbot_dns_luadns._internal.dns_luadns.""" - import sys -import unittest from unittest import mock import pytest @@ -17,7 +15,9 @@ TOKEN = 'foo' class AuthenticatorTest(test_util.TempDirTestCase, - dns_test_common_lexicon.BaseLexiconAuthenticatorTest): + dns_test_common_lexicon.BaseLexiconDNSAuthenticatorTest): + + LOGIN_ERROR = HTTPError("401 Client Error: Unauthorized for url: ...") def setUp(self): super().setUp() @@ -32,23 +32,6 @@ class AuthenticatorTest(test_util.TempDirTestCase, self.auth = Authenticator(self.config, "luadns") - self.mock_client = mock.MagicMock() - # _get_luadns_client | pylint: disable=protected-access - self.auth._get_luadns_client = mock.MagicMock(return_value=self.mock_client) - - -class LuaDNSLexiconClientTest(unittest.TestCase, dns_test_common_lexicon.BaseLexiconClientTest): - - LOGIN_ERROR = HTTPError("401 Client Error: Unauthorized for url: ...") - - def setUp(self): - from certbot_dns_luadns._internal.dns_luadns import _LuaDNSLexiconClient - - self.client = _LuaDNSLexiconClient(EMAIL, TOKEN, 0) - - self.provider_mock = mock.MagicMock() - self.client.provider = self.provider_mock - if __name__ == "__main__": sys.exit(pytest.main(sys.argv[1:] + [__file__])) # pragma: no cover diff --git a/certbot-dns-luadns/setup.py b/certbot-dns-luadns/setup.py index 8ce2f98ae..d7e8b0afb 100644 --- a/certbot-dns-luadns/setup.py +++ b/certbot-dns-luadns/setup.py @@ -7,7 +7,7 @@ from setuptools import setup version = '2.7.0.dev0' install_requires = [ - 'dns-lexicon>=3.2.1', + 'dns-lexicon>=3.14.1', 'setuptools>=41.6.0', ] diff --git a/certbot-dns-nsone/certbot_dns_nsone/_internal/dns_nsone.py b/certbot-dns-nsone/certbot_dns_nsone/_internal/dns_nsone.py index b4d1e801f..018a7f7c0 100644 --- a/certbot-dns-nsone/certbot_dns_nsone/_internal/dns_nsone.py +++ b/certbot-dns-nsone/certbot_dns_nsone/_internal/dns_nsone.py @@ -2,34 +2,31 @@ import logging from typing import Any from typing import Callable -from typing import cast from typing import Optional -from lexicon.providers import nsone from requests import HTTPError from certbot import errors -from certbot.plugins import dns_common from certbot.plugins import dns_common_lexicon -from certbot.plugins.dns_common import CredentialsConfiguration logger = logging.getLogger(__name__) ACCOUNT_URL = 'https://my.nsone.net/#/account/settings' -class Authenticator(dns_common.DNSAuthenticator): - """DNS Authenticator for NS1 - +class Authenticator(dns_common_lexicon.LexiconDNSAuthenticator): + """ + DNS Authenticator for NS1 This Authenticator uses the NS1 API to fulfill a dns-01 challenge. """ description = 'Obtain certificates using a DNS TXT record (if you are using NS1 for DNS).' - ttl = 60 def __init__(self, *args: Any, **kwargs: Any) -> None: super().__init__(*args, **kwargs) - self.credentials: Optional[CredentialsConfiguration] = None + self._add_provider_option('api-key', + f'API key for NS1 API, obtained from {ACCOUNT_URL}', + 'auth_token') @classmethod def add_parser_arguments(cls, add: Callable[..., None], @@ -41,42 +38,9 @@ class Authenticator(dns_common.DNSAuthenticator): return 'This plugin configures a DNS TXT record to respond to a dns-01 challenge using ' + \ 'the NS1 API.' - def _setup_credentials(self) -> None: - self.credentials = self._configure_credentials( - 'credentials', - 'NS1 credentials file', - { - 'api-key': 'API key for NS1 API, obtained from {0}'.format(ACCOUNT_URL) - } - ) - - def _perform(self, domain: str, validation_name: str, validation: str) -> None: - self._get_nsone_client().add_txt_record(domain, validation_name, validation) - - def _cleanup(self, domain: str, validation_name: str, validation: str) -> None: - self._get_nsone_client().del_txt_record(domain, validation_name, validation) - - def _get_nsone_client(self) -> "_NS1LexiconClient": - if not self.credentials: # pragma: no cover - raise errors.Error("Plugin has not been prepared.") - return _NS1LexiconClient(cast(str, self.credentials.conf('api-key')), self.ttl) - - -class _NS1LexiconClient(dns_common_lexicon.LexiconClient): - """ - Encapsulates all communication with the NS1 via Lexicon. - """ - - def __init__(self, api_key: str, ttl: int) -> None: - super().__init__() - - config = dns_common_lexicon.build_lexicon_config('nsone', { - 'ttl': ttl, - }, { - 'auth_token': api_key, - }) - - self.provider = nsone.Provider(config) + @property + def _provider_name(self) -> str: + return 'nsone' def _handle_http_error(self, e: HTTPError, domain_name: str) -> Optional[errors.PluginError]: if domain_name in str(e) and (str(e).startswith('404 Client Error: Not Found for url:') or diff --git a/certbot-dns-nsone/certbot_dns_nsone/_internal/tests/dns_nsone_test.py b/certbot-dns-nsone/certbot_dns_nsone/_internal/tests/dns_nsone_test.py index f4da5b4cc..ae0ff4c21 100644 --- a/certbot-dns-nsone/certbot_dns_nsone/_internal/tests/dns_nsone_test.py +++ b/certbot-dns-nsone/certbot_dns_nsone/_internal/tests/dns_nsone_test.py @@ -1,7 +1,5 @@ """Tests for certbot_dns_nsone._internal.dns_nsone.""" - import sys -import unittest from unittest import mock import pytest @@ -17,7 +15,10 @@ API_KEY = 'foo' class AuthenticatorTest(test_util.TempDirTestCase, - dns_test_common_lexicon.BaseLexiconAuthenticatorTest): + dns_test_common_lexicon.BaseLexiconDNSAuthenticatorTest): + + DOMAIN_NOT_FOUND = HTTPError(f'404 Client Error: Not Found for url: {DOMAIN}.') + LOGIN_ERROR = HTTPError(f'401 Client Error: Unauthorized for url: {DOMAIN}.') def setUp(self): super().setUp() @@ -32,23 +33,6 @@ class AuthenticatorTest(test_util.TempDirTestCase, self.auth = Authenticator(self.config, "nsone") - self.mock_client = mock.MagicMock() - # _get_nsone_client | pylint: disable=protected-access - self.auth._get_nsone_client = mock.MagicMock(return_value=self.mock_client) - - -class NS1LexiconClientTest(unittest.TestCase, dns_test_common_lexicon.BaseLexiconClientTest): - DOMAIN_NOT_FOUND = HTTPError('404 Client Error: Not Found for url: {0}.'.format(DOMAIN)) - LOGIN_ERROR = HTTPError('401 Client Error: Unauthorized for url: {0}.'.format(DOMAIN)) - - def setUp(self): - from certbot_dns_nsone._internal.dns_nsone import _NS1LexiconClient - - self.client = _NS1LexiconClient(API_KEY, 0) - - self.provider_mock = mock.MagicMock() - self.client.provider = self.provider_mock - if __name__ == "__main__": sys.exit(pytest.main(sys.argv[1:] + [__file__])) # pragma: no cover diff --git a/certbot-dns-nsone/setup.py b/certbot-dns-nsone/setup.py index 23610d824..614a7f9d7 100644 --- a/certbot-dns-nsone/setup.py +++ b/certbot-dns-nsone/setup.py @@ -7,7 +7,7 @@ from setuptools import setup version = '2.7.0.dev0' install_requires = [ - 'dns-lexicon>=3.2.1', + 'dns-lexicon>=3.14.1', 'setuptools>=41.6.0', ] diff --git a/certbot-dns-ovh/certbot_dns_ovh/_internal/dns_ovh.py b/certbot-dns-ovh/certbot_dns_ovh/_internal/dns_ovh.py index 9d327180b..42a953a43 100644 --- a/certbot-dns-ovh/certbot_dns_ovh/_internal/dns_ovh.py +++ b/certbot-dns-ovh/certbot_dns_ovh/_internal/dns_ovh.py @@ -2,34 +2,40 @@ import logging from typing import Any from typing import Callable -from typing import cast from typing import Optional -from lexicon.providers import ovh from requests import HTTPError from certbot import errors -from certbot.plugins import dns_common from certbot.plugins import dns_common_lexicon -from certbot.plugins.dns_common import CredentialsConfiguration logger = logging.getLogger(__name__) TOKEN_URL = 'https://eu.api.ovh.com/createToken/ or https://ca.api.ovh.com/createToken/' -class Authenticator(dns_common.DNSAuthenticator): +class Authenticator(dns_common_lexicon.LexiconDNSAuthenticator): """DNS Authenticator for OVH This Authenticator uses the OVH API to fulfill a dns-01 challenge. """ description = 'Obtain certificates using a DNS TXT record (if you are using OVH for DNS).' - ttl = 60 def __init__(self, *args: Any, **kwargs: Any) -> None: super().__init__(*args, **kwargs) - self.credentials: Optional[CredentialsConfiguration] = None + self._add_provider_option('endpoint', + 'OVH API endpoint (ovh-eu or ovh-ca)', + 'auth_entrypoint') + self._add_provider_option('application-key', + f'Application key for OVH API, obtained from {TOKEN_URL}', + 'auth_application_key') + self._add_provider_option('application-secret', + f'Application secret for OVH API, obtained from {TOKEN_URL}', + 'auth_application_secret') + self._add_provider_option('consumer-key', + f'Consumer key for OVH API, obtained from {TOKEN_URL}', + 'auth_consumer_key') @classmethod def add_parser_arguments(cls, add: Callable[..., None], @@ -41,58 +47,9 @@ class Authenticator(dns_common.DNSAuthenticator): return 'This plugin configures a DNS TXT record to respond to a dns-01 challenge using ' + \ 'the OVH API.' - def _setup_credentials(self) -> None: - self.credentials = self._configure_credentials( - 'credentials', - 'OVH credentials INI file', - { - 'endpoint': 'OVH API endpoint (ovh-eu or ovh-ca)', - 'application-key': 'Application key for OVH API, obtained from {0}' - .format(TOKEN_URL), - 'application-secret': 'Application secret for OVH API, obtained from {0}' - .format(TOKEN_URL), - 'consumer-key': 'Consumer key for OVH API, obtained from {0}' - .format(TOKEN_URL), - } - ) - - def _perform(self, domain: str, validation_name: str, validation: str) -> None: - self._get_ovh_client().add_txt_record(domain, validation_name, validation) - - def _cleanup(self, domain: str, validation_name: str, validation: str) -> None: - self._get_ovh_client().del_txt_record(domain, validation_name, validation) - - def _get_ovh_client(self) -> "_OVHLexiconClient": - if not self.credentials: # pragma: no cover - raise errors.Error("Plugin has not been prepared.") - return _OVHLexiconClient( - cast(str, self.credentials.conf('endpoint')), - cast(str, self.credentials.conf('application-key')), - cast(str, self.credentials.conf('application-secret')), - cast(str, self.credentials.conf('consumer-key')), - self.ttl - ) - - -class _OVHLexiconClient(dns_common_lexicon.LexiconClient): - """ - Encapsulates all communication with the OVH API via Lexicon. - """ - - def __init__(self, endpoint: str, application_key: str, application_secret: str, - consumer_key: str, ttl: int) -> None: - super().__init__() - - config = dns_common_lexicon.build_lexicon_config('ovh', { - 'ttl': ttl, - }, { - 'auth_entrypoint': endpoint, - 'auth_application_key': application_key, - 'auth_application_secret': application_secret, - 'auth_consumer_key': consumer_key, - }) - - self.provider = ovh.Provider(config) + @property + def _provider_name(self) -> str: + return 'ovh' def _handle_http_error(self, e: HTTPError, domain_name: str) -> errors.PluginError: hint = None diff --git a/certbot-dns-ovh/certbot_dns_ovh/_internal/tests/dns_ovh_test.py b/certbot-dns-ovh/certbot_dns_ovh/_internal/tests/dns_ovh_test.py index a83ec8470..2b054a304 100644 --- a/certbot-dns-ovh/certbot_dns_ovh/_internal/tests/dns_ovh_test.py +++ b/certbot-dns-ovh/certbot_dns_ovh/_internal/tests/dns_ovh_test.py @@ -1,8 +1,6 @@ """Tests for certbot_dns_ovh._internal.dns_ovh.""" - -import sys -import unittest from unittest import mock +import sys import pytest from requests.exceptions import HTTPError @@ -19,7 +17,10 @@ CONSUMER_KEY = 'spam' class AuthenticatorTest(test_util.TempDirTestCase, - dns_test_common_lexicon.BaseLexiconAuthenticatorTest): + dns_test_common_lexicon.BaseLexiconDNSAuthenticatorTest): + + DOMAIN_NOT_FOUND = Exception('Domain example.com not found') + LOGIN_ERROR = HTTPError('403 Client Error: Forbidden for url: https://eu.api.ovh.com/1.0/...') def setUp(self): super().setUp() @@ -38,26 +39,7 @@ class AuthenticatorTest(test_util.TempDirTestCase, self.config = mock.MagicMock(ovh_credentials=path, ovh_propagation_seconds=0) # don't wait during tests - self.auth = Authenticator(self.config, "ovh") - - self.mock_client = mock.MagicMock() - # _get_ovh_client | pylint: disable=protected-access - self.auth._get_ovh_client = mock.MagicMock(return_value=self.mock_client) - - -class OVHLexiconClientTest(unittest.TestCase, dns_test_common_lexicon.BaseLexiconClientTest): - DOMAIN_NOT_FOUND = Exception('Domain example.com not found') - LOGIN_ERROR = HTTPError('403 Client Error: Forbidden for url: https://eu.api.ovh.com/1.0/...') - - def setUp(self): - from certbot_dns_ovh._internal.dns_ovh import _OVHLexiconClient - - self.client = _OVHLexiconClient( - ENDPOINT, APPLICATION_KEY, APPLICATION_SECRET, CONSUMER_KEY, 0 - ) - - self.provider_mock = mock.MagicMock() - self.client.provider = self.provider_mock + self.auth = Authenticator(self.config, 'ovh') if __name__ == "__main__": diff --git a/certbot-dns-ovh/setup.py b/certbot-dns-ovh/setup.py index 37ccf7207..b56873f56 100644 --- a/certbot-dns-ovh/setup.py +++ b/certbot-dns-ovh/setup.py @@ -7,7 +7,7 @@ from setuptools import setup version = '2.7.0.dev0' install_requires = [ - 'dns-lexicon>=3.2.1', + 'dns-lexicon>=3.14.1', 'setuptools>=41.6.0', ] diff --git a/certbot-dns-sakuracloud/certbot_dns_sakuracloud/_internal/dns_sakuracloud.py b/certbot-dns-sakuracloud/certbot_dns_sakuracloud/_internal/dns_sakuracloud.py index 0100c0c9b..8f9934a0d 100644 --- a/certbot-dns-sakuracloud/certbot_dns_sakuracloud/_internal/dns_sakuracloud.py +++ b/certbot-dns-sakuracloud/certbot_dns_sakuracloud/_internal/dns_sakuracloud.py @@ -2,23 +2,19 @@ import logging from typing import Any from typing import Callable -from typing import cast from typing import Optional -from lexicon.providers import sakuracloud from requests import HTTPError from certbot import errors -from certbot.plugins import dns_common from certbot.plugins import dns_common_lexicon -from certbot.plugins.dns_common import CredentialsConfiguration logger = logging.getLogger(__name__) APIKEY_URL = "https://secure.sakura.ad.jp/cloud/#!/apikey/top/" -class Authenticator(dns_common.DNSAuthenticator): +class Authenticator(dns_common_lexicon.LexiconDNSAuthenticator): """DNS Authenticator for Sakura Cloud DNS This Authenticator uses the Sakura Cloud API to fulfill a dns-01 challenge. @@ -26,11 +22,15 @@ class Authenticator(dns_common.DNSAuthenticator): description = 'Obtain certificates using a DNS TXT record ' + \ '(if you are using Sakura Cloud for DNS).' - ttl = 60 def __init__(self, *args: Any, **kwargs: Any) -> None: super().__init__(*args, **kwargs) - self.credentials: Optional[CredentialsConfiguration] = None + self._add_provider_option('api-token', + f'API token for Sakura Cloud API obtained from {APIKEY_URL}', + 'auth_token') + self._add_provider_option('api-secret', + f'API secret for Sakura Cloud API obtained from {APIKEY_URL}', + 'auth_secret') @classmethod def add_parser_arguments(cls, add: Callable[..., None], @@ -43,50 +43,9 @@ class Authenticator(dns_common.DNSAuthenticator): return 'This plugin configures a DNS TXT record to respond to a dns-01 challenge using ' + \ 'the Sakura Cloud API.' - def _setup_credentials(self) -> None: - self.credentials = self._configure_credentials( - 'credentials', - 'Sakura Cloud credentials file', - { - 'api-token': f'API token for Sakura Cloud API obtained from {APIKEY_URL}', - 'api-secret': f'API secret for Sakura Cloud API obtained from {APIKEY_URL}', - } - ) - - def _perform(self, domain: str, validation_name: str, validation: str) -> None: - self._get_sakuracloud_client().add_txt_record( - domain, validation_name, validation) - - def _cleanup(self, domain: str, validation_name: str, validation: str) -> None: - self._get_sakuracloud_client().del_txt_record( - domain, validation_name, validation) - - def _get_sakuracloud_client(self) -> "_SakuraCloudLexiconClient": - if not self.credentials: # pragma: no cover - raise errors.Error("Plugin has not been prepared.") - return _SakuraCloudLexiconClient( - cast(str, self.credentials.conf('api-token')), - cast(str, self.credentials.conf('api-secret')), - self.ttl - ) - - -class _SakuraCloudLexiconClient(dns_common_lexicon.LexiconClient): - """ - Encapsulates all communication with the Sakura Cloud via Lexicon. - """ - - def __init__(self, api_token: str, api_secret: str, ttl: int) -> None: - super().__init__() - - config = dns_common_lexicon.build_lexicon_config('sakuracloud', { - 'ttl': ttl, - }, { - 'auth_token': api_token, - 'auth_secret': api_secret, - }) - - self.provider = sakuracloud.Provider(config) + @property + def _provider_name(self) -> str: + return 'sakuracloud' def _handle_http_error(self, e: HTTPError, domain_name: str) -> Optional[errors.PluginError]: if domain_name in str(e) and (str(e).startswith('404 Client Error: Not Found for url:')): diff --git a/certbot-dns-sakuracloud/certbot_dns_sakuracloud/_internal/tests/dns_sakuracloud_test.py b/certbot-dns-sakuracloud/certbot_dns_sakuracloud/_internal/tests/dns_sakuracloud_test.py index 2e8fccb55..e7e0b108e 100644 --- a/certbot-dns-sakuracloud/certbot_dns_sakuracloud/_internal/tests/dns_sakuracloud_test.py +++ b/certbot-dns-sakuracloud/certbot_dns_sakuracloud/_internal/tests/dns_sakuracloud_test.py @@ -1,7 +1,5 @@ """Tests for certbot_dns_sakuracloud._internal.dns_sakuracloud.""" - import sys -import unittest from unittest import mock import pytest @@ -16,8 +14,12 @@ from certbot.tests import util as test_util API_TOKEN = '00000000-0000-0000-0000-000000000000' API_SECRET = 'MDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAw' + class AuthenticatorTest(test_util.TempDirTestCase, - dns_test_common_lexicon.BaseLexiconAuthenticatorTest): + dns_test_common_lexicon.BaseLexiconDNSAuthenticatorTest): + + DOMAIN_NOT_FOUND = HTTPError(f'404 Client Error: Not Found for url: {DOMAIN}.') + LOGIN_ERROR = HTTPError(f'401 Client Error: Unauthorized for url: {DOMAIN}.') def setUp(self): super().setUp() @@ -35,24 +37,6 @@ class AuthenticatorTest(test_util.TempDirTestCase, self.auth = Authenticator(self.config, "sakuracloud") - self.mock_client = mock.MagicMock() - # _get_sakuracloud_client | pylint: disable=protected-access - self.auth._get_sakuracloud_client = mock.MagicMock(return_value=self.mock_client) - - -class SakuraCloudLexiconClientTest(unittest.TestCase, - dns_test_common_lexicon.BaseLexiconClientTest): - DOMAIN_NOT_FOUND = HTTPError('404 Client Error: Not Found for url: {0}.'.format(DOMAIN)) - LOGIN_ERROR = HTTPError('401 Client Error: Unauthorized for url: {0}.'.format(DOMAIN)) - - def setUp(self): - from certbot_dns_sakuracloud._internal.dns_sakuracloud import _SakuraCloudLexiconClient - - self.client = _SakuraCloudLexiconClient(API_TOKEN, API_SECRET, 0) - - self.provider_mock = mock.MagicMock() - self.client.provider = self.provider_mock - if __name__ == "__main__": sys.exit(pytest.main(sys.argv[1:] + [__file__])) # pragma: no cover diff --git a/certbot-dns-sakuracloud/setup.py b/certbot-dns-sakuracloud/setup.py index 7573774c5..07116e2d9 100644 --- a/certbot-dns-sakuracloud/setup.py +++ b/certbot-dns-sakuracloud/setup.py @@ -7,7 +7,7 @@ from setuptools import setup version = '2.7.0.dev0' install_requires = [ - 'dns-lexicon>=3.2.1', + 'dns-lexicon>=3.14.1', 'setuptools>=41.6.0', ] diff --git a/certbot/CHANGELOG.md b/certbot/CHANGELOG.md index 2f136c50d..0782d183d 100644 --- a/certbot/CHANGELOG.md +++ b/certbot/CHANGELOG.md @@ -7,6 +7,11 @@ Certbot adheres to [Semantic Versioning](https://semver.org/). ### Added * Add `certbot.util.LooseVersion` class. See [GH #9489](https://github.com/certbot/certbot/issues/9489). +* Add a new base class `certbot.plugins.dns_common_lexicon.LexiconDNSAuthenticator` to implement a DNS + authenticator plugin backed by Lexicon to communicate with the provider DNS API. This approach relies + heavily on conventions to reduce the implementation complexity of a new plugin. +* Add a new test base class `certbot.plugins.dns_test_common_lexicon.BaseLexiconDNSAuthenticatorTest` to + help testing DNS plugins implemented on top of `LexiconDNSAuthenticator`. ### Changed @@ -14,6 +19,10 @@ Certbot adheres to [Semantic Versioning](https://semver.org/). of global state previously needed to inspect whether a user set an argument or not. * Support for Python 3.7 was deprecated and will be removed in our next planned release. * Added `RENEWED_DOMAINS` and `FAILED_DOMAINS` environment variables for consumption by post renewal hooks. +* Deprecates `LexiconClient` base class and `build_lexicon_config` function in + `certbot.plugins.dns_common_lexicon` module in favor of `LexiconDNSAuthenticator`. +* Deprecates `BaseLexiconAuthenticatorTest` and `BaseLexiconClientTest` test base classes of + `certbot.plugins.dns_test_common_lexicon` module in favor of `BaseLexiconDNSAuthenticatorTest`. ### Fixed diff --git a/certbot/certbot/_internal/tests/plugins/dns_common_lexicon_test.py b/certbot/certbot/_internal/tests/plugins/dns_common_lexicon_test.py deleted file mode 100644 index 53bcb1a0d..000000000 --- a/certbot/certbot/_internal/tests/plugins/dns_common_lexicon_test.py +++ /dev/null @@ -1,29 +0,0 @@ -"""Tests for certbot.plugins.dns_common_lexicon.""" - -import sys -import unittest -from unittest import mock - -import pytest - -from certbot.plugins import dns_common_lexicon -from certbot.plugins import dns_test_common_lexicon - - -class LexiconClientTest(unittest.TestCase, dns_test_common_lexicon.BaseLexiconClientTest): - - class _FakeLexiconClient(dns_common_lexicon.LexiconClient): - pass - - def setUp(self): - super().setUp() - - self.client = LexiconClientTest._FakeLexiconClient() - self.provider_mock = mock.MagicMock() - - self.client.provider = self.provider_mock - - - -if __name__ == "__main__": - sys.exit(pytest.main(sys.argv[1:] + [__file__])) # pragma: no cover diff --git a/certbot/certbot/plugins/dns_common_lexicon.py b/certbot/certbot/plugins/dns_common_lexicon.py index 13ec33c71..d61534a10 100644 --- a/certbot/certbot/plugins/dns_common_lexicon.py +++ b/certbot/certbot/plugins/dns_common_lexicon.py @@ -1,14 +1,22 @@ """Common code for DNS Authenticator Plugins built on Lexicon.""" +import abc import logging +import sys +from types import ModuleType from typing import Any +from typing import cast from typing import Dict +from typing import List from typing import Mapping from typing import Optional +from typing import Tuple from typing import Union +import warnings from requests.exceptions import HTTPError from requests.exceptions import RequestException +from certbot import configuration from certbot import errors from certbot.plugins import dns_common @@ -18,18 +26,23 @@ from certbot.plugins import dns_common # always importable, even if it does not make sense to use it # if Lexicon is not available, obviously. try: + from lexicon.client import Client from lexicon.config import ConfigResolver - from lexicon.providers.base import Provider -except ImportError: + from lexicon.interfaces import Provider +except ImportError: # pragma: no cover + Client = None ConfigResolver = None Provider = None logger = logging.getLogger(__name__) -class LexiconClient: +class LexiconClient: # pragma: no cover """ Encapsulates all communication with a DNS provider via Lexicon. + + .. deprecated:: 2.7.0 + Please use certbot.plugins.dns_common_lexicon.LexiconDNSAuthenticator instead. """ def __init__(self) -> None: @@ -122,14 +135,18 @@ class LexiconClient: def build_lexicon_config(lexicon_provider_name: str, lexicon_options: Mapping[str, Any], provider_options: Mapping[str, Any] - ) -> Union[ConfigResolver, Dict[str, Any]]: + ) -> Union[ConfigResolver, Dict[str, Any]]: # pragma: no cover """ Convenient function to build a Lexicon 2.x/3.x config object. + :param str lexicon_provider_name: the name of the lexicon provider to use :param dict lexicon_options: options specific to lexicon :param dict provider_options: options specific to provider :return: configuration to apply to the provider :rtype: ConfigurationResolver or dict + + .. deprecated:: 2.7.0 + Please use certbot.plugins.dns_common_lexicon.LexiconDNSAuthenticator instead. """ config: Union[ConfigResolver, Dict[str, Any]] = {'provider_name': lexicon_provider_name} config.update(lexicon_options) @@ -144,3 +161,143 @@ def build_lexicon_config(lexicon_provider_name: str, config = ConfigResolver().with_dict(config).with_env() return config + + +class LexiconDNSAuthenticator(dns_common.DNSAuthenticator): + """ + Base class for a DNS authenticator that uses Lexicon client + as backend to execute DNS record updates + """ + + def __init__(self, config: configuration.NamespaceConfig, name: str): + super().__init__(config, name) + self._provider_options: List[Tuple[str, str, str]] = [] + self._credentials: dns_common.CredentialsConfiguration + + @property + @abc.abstractmethod + def _provider_name(self) -> str: + """ + The name of the Lexicon provider to use + """ + + @property + def _ttl(self) -> int: + """ + Time to live to apply to the DNS records created by this Authenticator + """ + return 60 + + def _add_provider_option(self, creds_var_name: str, creds_var_label: str, + lexicon_provider_option_name: str) -> None: + self._provider_options.append( + (creds_var_name, creds_var_label, lexicon_provider_option_name)) + + def _build_lexicon_config(self, domain: str) -> ConfigResolver: + if not hasattr(self, '_credentials'): # pragma: no cover + self._setup_credentials() + + dict_config = { + 'domain': domain, + 'provider_name': self._provider_name, + 'ttl': self._ttl, + self._provider_name: {item[2]: self._credentials.conf(item[0]) + for item in self._provider_options} + } + return ConfigResolver().with_dict(dict_config).with_env() + + def _setup_credentials(self) -> None: + self._credentials = self._configure_credentials( + key='credentials', + label=f'Credentials INI file for {self._provider_name} DNS authenticator', + required_variables={item[0]: item[1] for item in self._provider_options}, + ) + + def _perform(self, domain: str, validation_name: str, validation: str) -> None: + resolved_domain = self._resolve_domain(domain) + + try: + with Client(self._build_lexicon_config(resolved_domain)) as operations: + operations.create_record(rtype='TXT', name=validation_name, content=validation) + except RequestException as e: + logger.debug('Encountered error adding TXT record: %s', e, exc_info=True) + raise errors.PluginError('Error adding TXT record: {0}'.format(e)) + + def _cleanup(self, domain: str, validation_name: str, validation: str) -> None: + try: + resolved_domain = self._resolve_domain(domain) + except errors.PluginError as e: + logger.debug('Encountered error finding domain_id during deletion: %s', e, + exc_info=True) + return + + try: + with Client(self._build_lexicon_config(resolved_domain)) as operations: + operations.delete_record(rtype='TXT', name=validation_name, content=validation) + except RequestException as e: + logger.debug('Encountered error deleting TXT record: %s', e, exc_info=True) + + def _resolve_domain(self, domain: str) -> str: + domain_name_guesses = dns_common.base_domain_name_guesses(domain) + + for domain_name in domain_name_guesses: + try: + # Using client as a context manager requires `dns-lexicon>=3.14` and we may want to + # provide better checks and error handling around this in the future. + with Client(self._build_lexicon_config(domain_name)): + return domain_name + except HTTPError as e: + result1 = self._handle_http_error(e, domain_name) + + if result1: + raise result1 + except Exception as e: # pylint: disable=broad-except + result2 = self._handle_general_error(e, domain_name) + + if result2: + raise result2 # pylint: disable=raising-bad-type + + raise errors.PluginError('Unable to determine zone identifier for {0} using zone names: {1}' + .format(domain, domain_name_guesses)) + + def _handle_http_error(self, e: HTTPError, domain_name: str) -> Optional[errors.PluginError]: + return errors.PluginError('Error determining zone identifier for {0}: {1}.' + .format(domain_name, e)) + + def _handle_general_error(self, e: Exception, domain_name: str) -> Optional[errors.PluginError]: + if not str(e).startswith('No domain found'): + return errors.PluginError('Unexpected error determining zone identifier for {0}: {1}' + .format(domain_name, e)) + return None + + +# This class takes a similar approach to the cryptography project to deprecate attributes +# in public modules. See the _ModuleWithDeprecation class here: +# https://github.com/pyca/cryptography/blob/91105952739442a74582d3e62b3d2111365b0dc7/src/cryptography/utils.py#L129 +class _DeprecationModule: + """ + Internal class delegating to a module, and displaying warnings when attributes + related to deprecated attributes in the current module. + """ + def __init__(self, module: ModuleType): + self.__dict__['_module'] = module + + def __getattr__(self, attr: str) -> Any: + if attr in ('LexiconClient', 'build_lexicon_config'): + warnings.warn(f'{attr} attribute in {__name__} module is deprecated ' + 'and will be removed soon.', + DeprecationWarning, stacklevel=2) + return getattr(self._module, attr) + + def __setattr__(self, attr: str, value: Any) -> None: # pragma: no cover + setattr(self._module, attr, value) + + def __delattr__(self, attr: str) -> Any: # pragma: no cover + delattr(self._module, attr) + + def __dir__(self) -> List[str]: # pragma: no cover + return ['_module'] + dir(self._module) + + +# Patching ourselves to warn about deprecation and planned removal of some elements in the module. +sys.modules[__name__] = cast(ModuleType, _DeprecationModule(sys.modules[__name__])) diff --git a/certbot/certbot/plugins/dns_test_common.py b/certbot/certbot/plugins/dns_test_common.py index cb89cd4d9..24580f506 100644 --- a/certbot/certbot/plugins/dns_test_common.py +++ b/certbot/certbot/plugins/dns_test_common.py @@ -40,6 +40,12 @@ class _AuthenticatorCallableTestCase(Protocol): https://docs.python.org/3/library/unittest.html#unittest.TestCase.assertEqual """ + def assertRaises(self, *unused_args: Any) -> None: + """ + See + https://docs.python.org/3/library/unittest.html#unittest.TestCase.assertRaises + """ + class BaseAuthenticatorTest: """ diff --git a/certbot/certbot/plugins/dns_test_common_lexicon.py b/certbot/certbot/plugins/dns_test_common_lexicon.py index 371040404..f1de1b76e 100644 --- a/certbot/certbot/plugins/dns_test_common_lexicon.py +++ b/certbot/certbot/plugins/dns_test_common_lexicon.py @@ -1,8 +1,16 @@ """Base test class for DNS authenticators built on Lexicon.""" +import contextlib +import sys +from types import ModuleType from typing import Any +from typing import cast +from typing import Generator +from typing import List +from typing import Tuple from typing import TYPE_CHECKING from unittest import mock from unittest.mock import MagicMock +import warnings import josepy as jose from requests.exceptions import HTTPError @@ -11,11 +19,15 @@ from requests.exceptions import RequestException from certbot import errors from certbot.achallenges import AnnotatedChallenge from certbot.plugins import dns_test_common -from certbot.plugins.dns_common_lexicon import LexiconClient + +with warnings.catch_warnings(): + warnings.filterwarnings("ignore", category=DeprecationWarning) + from certbot.plugins.dns_common_lexicon import LexiconClient + from certbot.plugins.dns_test_common import _AuthenticatorCallableTestCase from certbot.tests import util as test_util -if TYPE_CHECKING: +if TYPE_CHECKING: # pragma: no cover from typing_extensions import Protocol else: Protocol = object @@ -23,6 +35,11 @@ else: DOMAIN = 'example.com' KEY = jose.JWKRSA.load(test_util.load_vector("rsa512_key.pem")) +DOMAIN_NOT_FOUND = Exception('No domain found') +GENERIC_ERROR = RequestException +LOGIN_ERROR = HTTPError('400 Client Error: ...') +UNKNOWN_LOGIN_ERROR = HTTPError('500 Surprise! Error: ...') + class _AuthenticatorCallableLexiconTestCase(_AuthenticatorCallableTestCase, Protocol): """ @@ -59,7 +76,7 @@ class _LexiconAwareTestCase(Protocol): # These classes are intended to be subclassed/mixed in, so not all members are defined. # pylint: disable=no-member -class BaseLexiconAuthenticatorTest(dns_test_common.BaseAuthenticatorTest): +class BaseLexiconAuthenticatorTest(dns_test_common.BaseAuthenticatorTest): # pragma: no cover @test_util.patch_display_util() def test_perform(self: _AuthenticatorCallableLexiconTestCase, @@ -77,11 +94,11 @@ class BaseLexiconAuthenticatorTest(dns_test_common.BaseAuthenticatorTest): self.assertEqual(expected, self.mock_client.mock_calls) -class BaseLexiconClientTest: - DOMAIN_NOT_FOUND = Exception('No domain found') - GENERIC_ERROR = RequestException - LOGIN_ERROR = HTTPError('400 Client Error: ...') - UNKNOWN_LOGIN_ERROR = HTTPError('500 Surprise! Error: ...') +class BaseLexiconClientTest: # pragma: no cover + DOMAIN_NOT_FOUND = DOMAIN_NOT_FOUND + GENERIC_ERROR = GENERIC_ERROR + LOGIN_ERROR = LOGIN_ERROR + UNKNOWN_LOGIN_ERROR = UNKNOWN_LOGIN_ERROR record_prefix = "_acme-challenge" record_name = record_prefix + "." + DOMAIN @@ -175,3 +192,169 @@ class BaseLexiconClientTest: self.provider_mock.delete_record.side_effect = self.GENERIC_ERROR self.client.del_txt_record(DOMAIN, self.record_name, self.record_content) + + +class _BaseLexiconDNSAuthenticatorTestProto(_AuthenticatorCallableTestCase, Protocol): + """Protocol for BaseLexiconDNSAuthenticatorTest instances""" + DOMAIN_NOT_FOUND: Exception + GENERIC_ERROR: Exception + LOGIN_ERROR: Exception + UNKNOWN_LOGIN_ERROR: Exception + + achall: AnnotatedChallenge + + +class BaseLexiconDNSAuthenticatorTest(dns_test_common.BaseAuthenticatorTest): + + DOMAIN_NOT_FOUND = DOMAIN_NOT_FOUND + GENERIC_ERROR = GENERIC_ERROR + LOGIN_ERROR = LOGIN_ERROR + UNKNOWN_LOGIN_ERROR = UNKNOWN_LOGIN_ERROR + + def test_perform_succeed(self: _BaseLexiconDNSAuthenticatorTestProto) -> None: + with test_util.patch_display_util(): + with _patch_lexicon_client() as (mock_client, mock_operations): + self.auth.perform([self.achall]) + + mock_client.assert_called() + config = mock_client.call_args[0][0] + self.assertEqual(DOMAIN, config.resolve('lexicon:domain')) + + mock_operations.create_record.assert_called_with( + rtype='TXT', name=f'_acme-challenge.{DOMAIN}', content=mock.ANY) + + def test_perform_with_one_domain_resolution_failure_succeed( + self: _BaseLexiconDNSAuthenticatorTestProto) -> None: + with test_util.patch_display_util(): + with _patch_lexicon_client() as (mock_client, mock_operations): + mock_client.return_value.__enter__.side_effect = [ + self.DOMAIN_NOT_FOUND, # First resolution domain attempt + mock_operations, # Second resolution domain attempt + mock_operations, # Create record operation + ] + self.auth.perform([self.achall]) + + def test_perform_with_two_domain_resolution_failures_raise( + self: _BaseLexiconDNSAuthenticatorTestProto) -> None: + with test_util.patch_display_util(): + with _patch_lexicon_client() as (mock_client, _): + mock_client.return_value.__enter__.side_effect = self.DOMAIN_NOT_FOUND + self.assertRaises(errors.PluginError, + self.auth.perform, + [self.achall]) + + def test_perform_with_domain_resolution_general_failure_raise( + self: _BaseLexiconDNSAuthenticatorTestProto) -> None: + with test_util.patch_display_util(): + with _patch_lexicon_client() as (mock_client, _): + mock_client.return_value.__enter__.side_effect = self.GENERIC_ERROR + self.assertRaises(errors.PluginError, + self.auth.perform, + [self.achall]) + + def test_perform_with_auth_failure_raise(self: _BaseLexiconDNSAuthenticatorTestProto) -> None: + with test_util.patch_display_util(): + with _patch_lexicon_client() as (mock_client, _): + mock_client.side_effect = self.LOGIN_ERROR + self.assertRaises(errors.PluginError, + self.auth.perform, + [self.achall]) + + def test_perform_with_unknown_auth_failure_raise( + self: _BaseLexiconDNSAuthenticatorTestProto) -> None: + with test_util.patch_display_util(): + with _patch_lexicon_client() as (mock_client, _): + mock_client.side_effect = self.UNKNOWN_LOGIN_ERROR + self.assertRaises(errors.PluginError, + self.auth.perform, + [self.achall]) + + def test_perform_with_create_record_failure_raise( + self: _BaseLexiconDNSAuthenticatorTestProto) -> None: + with test_util.patch_display_util(): + with _patch_lexicon_client() as (_, mock_operations): + mock_operations.create_record.side_effect = self.GENERIC_ERROR + self.assertRaises(errors.PluginError, + self.auth.perform, + [self.achall]) + + def test_cleanup_success(self: _BaseLexiconDNSAuthenticatorTestProto) -> None: + self.auth._attempt_cleanup = True # _attempt_cleanup | pylint: disable=protected-access + with _patch_lexicon_client() as (mock_client, mock_operations): + self.auth.cleanup([self.achall]) + + mock_client.assert_called() + config = mock_client.call_args[0][0] + self.assertEqual(DOMAIN, config.resolve('lexicon:domain')) + + mock_operations.delete_record.assert_called_with( + rtype='TXT', name=f'_acme-challenge.{DOMAIN}', content=mock.ANY) + + def test_cleanup_with_auth_failure_ignore(self: _BaseLexiconDNSAuthenticatorTestProto) -> None: + with _patch_lexicon_client() as (mock_client, _): + mock_client.side_effect = self.LOGIN_ERROR + self.auth.cleanup([self.achall]) + + def test_cleanup_with_unknown_auth_failure_ignore( + self: _BaseLexiconDNSAuthenticatorTestProto) -> None: + with _patch_lexicon_client() as (mock_client, _): + mock_client.side_effect = self.LOGIN_ERROR + self.auth.cleanup([self.achall]) + + def test_cleanup_with_domain_resolution_failure_ignore( + self: _BaseLexiconDNSAuthenticatorTestProto) -> None: + with _patch_lexicon_client() as (mock_client, _): + mock_client.return_value.__enter__.side_effect = self.DOMAIN_NOT_FOUND + self.auth.cleanup([self.achall]) + + def test_cleanup_with_domain_resolution_general_failure_ignore( + self: _BaseLexiconDNSAuthenticatorTestProto) -> None: + with _patch_lexicon_client() as (mock_client, _): + mock_client.return_value.__enter__.side_effect = self.GENERIC_ERROR + self.auth.cleanup([self.achall]) + + def test_cleanup_with_delete_record_failure_ignore( + self: _BaseLexiconDNSAuthenticatorTestProto) -> None: + with _patch_lexicon_client() as (_, mock_operations): + mock_operations.create_record.side_effect = self.GENERIC_ERROR + self.auth.cleanup([self.achall]) + + +@contextlib.contextmanager +def _patch_lexicon_client() -> Generator[Tuple[MagicMock, MagicMock], None, None]: + with mock.patch('certbot.plugins.dns_common_lexicon.Client') as mock_client: + mock_operations = MagicMock() + mock_client.return_value.__enter__.return_value = mock_operations + yield mock_client, mock_operations + + +# This class takes a similar approach to the cryptography project to deprecate attributes +# in public modules. See the _ModuleWithDeprecation class here: +# https://github.com/pyca/cryptography/blob/91105952739442a74582d3e62b3d2111365b0dc7/src/cryptography/utils.py#L129 +class _DeprecationModule: + """ + Internal class delegating to a module, and displaying warnings when attributes + related to deprecated attributes in the current module. + """ + def __init__(self, module: ModuleType): + self.__dict__['_module'] = module + + def __getattr__(self, attr: str) -> Any: + if attr in ('BaseLexiconAuthenticatorTest', 'BaseLexiconClientTest'): + warnings.warn(f'{attr} attribute in {__name__} module is deprecated ' + 'and will be removed soon.', + DeprecationWarning, stacklevel=2) + return getattr(self._module, attr) + + def __setattr__(self, attr: str, value: Any) -> None: # pragma: no cover + setattr(self._module, attr, value) + + def __delattr__(self, attr: str) -> Any: # pragma: no cover + delattr(self._module, attr) + + def __dir__(self) -> List[str]: # pragma: no cover + return ['_module'] + dir(self._module) + + +# Patching ourselves to warn about deprecation and planned removal of some elements in the module. +sys.modules[__name__] = cast(ModuleType, _DeprecationModule(sys.modules[__name__])) diff --git a/tools/oldest_constraints.txt b/tools/oldest_constraints.txt index 283a0ab57..9cdff4f24 100644 --- a/tools/oldest_constraints.txt +++ b/tools/oldest_constraints.txt @@ -4,6 +4,7 @@ apacheconfig==0.3.2 ; python_version >= "3.7" and python_version < "3.8" appdirs==1.4.4 ; python_version >= "3.7" and python_version < "3.8" asn1crypto==0.24.0 ; python_version >= "3.7" and python_version < "3.8" astroid==2.15.6 ; python_full_version >= "3.7.2" and python_version < "3.8" +beautifulsoup4==4.12.2 ; python_version >= "3.7" and python_version < "3.8" boto3==1.15.15 ; python_version >= "3.7" and python_version < "3.8" botocore==1.18.15 ; python_version >= "3.7" and python_version < "3.8" cachetools==5.3.1 ; python_version >= "3.7" and python_version < "3.8" @@ -11,7 +12,7 @@ certifi==2023.7.22 ; python_version >= "3.7" and python_version < "3.8" cffi==1.11.5 ; python_version >= "3.7" and python_version < "3.8" chardet==3.0.4 ; python_version >= "3.7" and python_version < "3.8" cloudflare==1.5.1 ; python_version >= "3.7" and python_version < "3.8" -colorama==0.4.6 ; python_version >= "3.7" and python_version < "3.8" and sys_platform == "win32" +colorama==0.4.6 ; python_version < "3.8" and sys_platform == "win32" and python_version >= "3.7" configargparse==1.5.3 ; python_version >= "3.7" and python_version < "3.8" configobj==5.0.6 ; python_version >= "3.7" and python_version < "3.8" coverage==7.2.7 ; python_version >= "3.7" and python_version < "3.8" @@ -20,7 +21,7 @@ cython==0.29.36 ; python_version >= "3.7" and python_version < "3.8" dill==0.3.7 ; python_full_version >= "3.7.2" and python_version < "3.8" distlib==0.3.7 ; python_version >= "3.7" and python_version < "3.8" distro==1.0.1 ; python_version >= "3.7" and python_version < "3.8" -dns-lexicon==3.2.1 ; python_version >= "3.7" and python_version < "3.8" +dns-lexicon==3.14.1 ; python_version >= "3.7" and python_version < "3.8" dnspython==1.15.0 ; python_version >= "3.7" and python_version < "3.8" exceptiongroup==1.1.3 ; python_version >= "3.7" and python_version < "3.8" execnet==2.0.2 ; python_version >= "3.7" and python_version < "3.8" @@ -75,11 +76,12 @@ rsa==4.9 ; python_version >= "3.7" and python_version < "3.8" s3transfer==0.3.7 ; python_version >= "3.7" and python_version < "3.8" setuptools==41.6.0 ; python_version >= "3.7" and python_version < "3.8" six==1.11.0 ; python_version >= "3.7" and python_version < "3.8" +soupsieve==2.4.1 ; python_version >= "3.7" and python_version < "3.8" tldextract==3.5.0 ; python_version >= "3.7" and python_version < "3.8" -tomli==2.0.1 ; python_version >= "3.7" and python_version < "3.8" +tomli==2.0.1 ; python_version < "3.8" and python_version >= "3.7" tomlkit==0.12.1 ; python_full_version >= "3.7.2" and python_version < "3.8" tox==1.9.2 ; python_version >= "3.7" and python_version < "3.8" -typed-ast==1.5.5 ; python_version >= "3.7" and python_version < "3.8" +typed-ast==1.5.5 ; python_version < "3.8" and python_version >= "3.7" types-cryptography==3.3.23.2 ; python_version >= "3.7" and python_version < "3.8" types-httplib2==0.22.0.2 ; python_version >= "3.7" and python_version < "3.8" types-pyopenssl==23.0.0.0 ; python_version >= "3.7" and python_version < "3.8" @@ -91,7 +93,7 @@ types-requests==2.31.0.2 ; python_version >= "3.7" and python_version < "3.8" types-setuptools==68.2.0.0 ; python_version >= "3.7" and python_version < "3.8" types-six==1.16.21.9 ; python_version >= "3.7" and python_version < "3.8" types-urllib3==1.26.25.14 ; python_version >= "3.7" and python_version < "3.8" -typing-extensions==4.7.1 ; python_version >= "3.7" and python_version < "3.8" +typing-extensions==4.7.1 ; python_version < "3.8" and python_version >= "3.7" uritemplate==3.0.1 ; python_version >= "3.7" and python_version < "3.8" urllib3==1.24.2 ; python_version >= "3.7" and python_version < "3.8" virtualenv==20.4.7 ; python_version >= "3.7" and python_version < "3.8" diff --git a/tools/pinning/current/pyproject.toml b/tools/pinning/current/pyproject.toml index 9d0fb3136..44d5dc9d1 100644 --- a/tools/pinning/current/pyproject.toml +++ b/tools/pinning/current/pyproject.toml @@ -75,11 +75,6 @@ poetry = "<1.3.0" # https://github.com/certbot/certbot/issues/9606. setuptools = "<67.5.0" -# Lexicon 3.14+ deprecates several private APIs and create stubs packages, leading -# to several warnings and lint/mypy errors. Let's pin it to 3.13 until this is fixed -# with https://github.com/certbot/certbot/pull/9746. -dns-lexicon = "<3.14" - [tool.poetry.dev-dependencies] [build-system] diff --git a/tools/pinning/oldest/pyproject.toml b/tools/pinning/oldest/pyproject.toml index 28e0ccabe..58e763be3 100644 --- a/tools/pinning/oldest/pyproject.toml +++ b/tools/pinning/oldest/pyproject.toml @@ -54,7 +54,7 @@ cloudflare = "1.5.1" configobj = "5.0.6" cryptography = "3.2.1" distro = "1.0.1" -dns-lexicon = "3.2.1" +dns-lexicon = "3.14.1" dnspython = "1.15.0" funcsigs = "0.4" google-api-python-client = "1.6.5" diff --git a/tools/requirements.txt b/tools/requirements.txt index 724975055..2d134b272 100644 --- a/tools/requirements.txt +++ b/tools/requirements.txt @@ -18,8 +18,8 @@ backports-cached-property==1.0.2 ; python_version >= "3.7" and python_version < bcrypt==4.0.1 ; python_version >= "3.7" and python_version < "4.0" beautifulsoup4==4.12.2 ; python_version >= "3.7" and python_version < "4.0" bleach==6.0.0 ; python_version >= "3.7" and python_version < "4.0" -boto3==1.28.43 ; python_version >= "3.7" and python_version < "4.0" -botocore==1.31.43 ; python_version >= "3.7" and python_version < "4.0" +boto3==1.28.45 ; python_version >= "3.7" and python_version < "4.0" +botocore==1.31.45 ; python_version >= "3.7" and python_version < "4.0" cachecontrol==0.12.14 ; python_version >= "3.7" and python_version < "4.0" cachetools==5.3.1 ; python_version >= "3.7" and python_version < "4.0" cachy==0.3.0 ; python_version >= "3.7" and python_version < "4.0" @@ -28,7 +28,7 @@ cffi==1.15.1 ; python_version >= "3.7" and python_version < "4.0" charset-normalizer==3.2.0 ; python_version >= "3.7" and python_version < "4.0" cleo==1.0.0a5 ; python_version >= "3.7" and python_version < "4.0" cloudflare==2.11.7 ; python_version >= "3.7" and python_version < "4.0" -colorama==0.4.6 ; python_version >= "3.7" and python_version < "4.0" and (sys_platform == "win32" or platform_system == "Windows") +colorama==0.4.6 ; python_version < "4.0" and sys_platform == "win32" and python_version >= "3.7" or python_version >= "3.7" and python_version < "4.0" and platform_system == "Windows" configargparse==1.7 ; python_version >= "3.7" and python_version < "4.0" configobj==5.0.8 ; python_version >= "3.7" and python_version < "4.0" coverage==7.2.7 ; python_version >= "3.7" and python_version < "4.0" @@ -40,18 +40,18 @@ deprecated==1.2.14 ; python_version >= "3.7" and python_version < "4.0" dill==0.3.7 ; python_full_version >= "3.7.2" and python_version < "4.0" distlib==0.3.7 ; python_version >= "3.7" and python_version < "4.0" distro==1.8.0 ; python_version >= "3.7" and python_version < "4.0" -dns-lexicon==3.13.0 ; python_version >= "3.7" and python_version < "4.0" +dns-lexicon==3.14.1 ; python_version >= "3.7" and python_version < "4.0" dnspython==2.3.0 ; python_version >= "3.7" and python_version < "4.0" -docutils==0.19 ; python_version >= "3.7" and python_version < "4.0" +docutils==0.18.1 ; python_version >= "3.7" and python_version < "4.0" dulwich==0.20.50 ; python_version >= "3.7" and python_version < "4.0" exceptiongroup==1.1.3 ; python_version >= "3.7" and python_version < "3.11" execnet==2.0.2 ; python_version >= "3.7" and python_version < "4.0" fabric==3.2.2 ; python_version >= "3.7" and python_version < "4.0" filelock==3.12.2 ; python_version >= "3.7" and python_version < "4.0" google-api-core==2.11.1 ; python_version >= "3.7" and python_version < "4.0" -google-api-python-client==2.98.0 ; python_version >= "3.7" and python_version < "4.0" -google-auth-httplib2==0.1.0 ; python_version >= "3.7" and python_version < "4.0" -google-auth==2.22.0 ; python_version >= "3.7" and python_version < "4.0" +google-api-python-client==2.99.0 ; python_version >= "3.7" and python_version < "4.0" +google-auth-httplib2==0.1.1 ; python_version >= "3.7" and python_version < "4.0" +google-auth==2.23.0 ; python_version >= "3.7" and python_version < "4.0" googleapis-common-protos==1.60.0 ; python_version >= "3.7" and python_version < "4.0" html5lib==1.1 ; python_version >= "3.7" and python_version < "4.0" httplib2==0.22.0 ; python_version >= "3.7" and python_version < "4.0" @@ -97,7 +97,7 @@ pickleshare==0.7.5 ; python_version >= "3.7" and python_version < "4.0" pip==23.2.1 ; python_version >= "3.7" and python_version < "4.0" pkginfo==1.9.6 ; python_version >= "3.7" and python_version < "4.0" pkgutil-resolve-name==1.3.10 ; python_version >= "3.7" and python_version < "3.9" -platformdirs==2.6.2 ; python_version >= "3.7" and python_version < "4.0" +platformdirs==2.6.2 ; python_version < "4.0" and python_version >= "3.7" pluggy==1.2.0 ; python_version >= "3.7" and python_version < "4.0" ply==3.11 ; python_version >= "3.7" and python_version < "4.0" poetry-core==1.3.2 ; python_version >= "3.7" and python_version < "4.0" @@ -147,21 +147,22 @@ shellingham==1.5.3 ; python_version >= "3.7" and python_version < "4.0" six==1.16.0 ; python_version >= "3.7" and python_version < "4.0" snowballstemmer==2.2.0 ; python_version >= "3.7" and python_version < "4.0" soupsieve==2.4.1 ; python_version >= "3.7" and python_version < "4.0" -sphinx-rtd-theme==0.5.1 ; python_version >= "3.7" and python_version < "4.0" +sphinx-rtd-theme==1.3.0 ; python_version >= "3.7" and python_version < "4.0" sphinx==5.3.0 ; python_version >= "3.7" and python_version < "4.0" sphinxcontrib-applehelp==1.0.2 ; python_version >= "3.7" and python_version < "4.0" sphinxcontrib-devhelp==1.0.2 ; python_version >= "3.7" and python_version < "4.0" sphinxcontrib-htmlhelp==2.0.0 ; python_version >= "3.7" and python_version < "4.0" +sphinxcontrib-jquery==4.1 ; python_version >= "3.7" and python_version < "4.0" sphinxcontrib-jsmath==1.0.1 ; python_version >= "3.7" and python_version < "4.0" sphinxcontrib-qthelp==1.0.3 ; python_version >= "3.7" and python_version < "4.0" sphinxcontrib-serializinghtml==1.1.5 ; python_version >= "3.7" and python_version < "4.0" tldextract==3.5.0 ; python_version >= "3.7" and python_version < "4.0" tomli==2.0.1 ; python_version >= "3.7" and python_full_version <= "3.11.0a6" -tomlkit==0.12.1 ; python_version >= "3.7" and python_version < "4.0" +tomlkit==0.12.1 ; python_version < "4.0" and python_version >= "3.7" tox==3.28.0 ; python_version >= "3.7" and python_version < "4.0" traitlets==5.9.0 ; python_version >= "3.7" and python_version < "4.0" twine==4.0.2 ; python_version >= "3.7" and python_version < "4.0" -typed-ast==1.5.5 ; python_version >= "3.7" and python_version < "3.8" +typed-ast==1.5.5 ; python_version < "3.8" and python_version >= "3.7" types-httplib2==0.22.0.2 ; python_version >= "3.7" and python_version < "4.0" types-pyopenssl==23.2.0.2 ; python_version >= "3.7" and python_version < "4.0" types-pyrfc3339==1.1.1.5 ; python_version >= "3.7" and python_version < "4.0" @@ -179,7 +180,7 @@ virtualenv==20.21.1 ; python_version >= "3.7" and python_version < "4.0" wcwidth==0.2.6 ; python_version >= "3.7" and python_version < "4.0" webencodings==0.5.1 ; python_version >= "3.7" and python_version < "4.0" wheel==0.41.2 ; python_version >= "3.7" and python_version < "4.0" -wrapt==1.15.0 ; python_version >= "3.7" and python_version < "4.0" +wrapt==1.15.0 ; python_version < "4.0" and python_version >= "3.7" xattr==0.9.9 ; python_version >= "3.7" and python_version < "4.0" and sys_platform == "darwin" yarg==0.1.9 ; python_version >= "3.7" and python_version < "4.0" zipp==3.15.0 ; python_version >= "3.7" and python_version < "4.0"