mirror of
https://github.com/certbot/certbot.git
synced 2026-06-10 17:20:36 -04:00
Migrate certbot-dns-cloudflare to cloudflare 4.x SDK (#10587)
## Summary - Migrate `certbot-dns-cloudflare` from the archived `python-cloudflare` 2.x library (`import CloudFlare`) to the actively maintained Cloudflare Python SDK 4.x (`import cloudflare`) - Update all API calls to the new SDK surface: `dns.records.create/list/delete`, `zones.list`, typed response objects instead of dicts - Replace `CloudFlare.exceptions.CloudFlareAPIError` with `cloudflare.APIStatusError` and extract CF error codes from `response.json()` - Bump dependency from `cloudflare>=2.19, <2.20` to `cloudflare>=4.0` - Update oldest pinning from `cloudflare 2.19` to `4.0.0` - Update all test mocks and assertions accordingly Fixes #9938 ## API Migration | Operation | Old 2.x | New 4.x | |---|---|---| | Import | `import CloudFlare` | `import cloudflare` | | Client (token) | `CloudFlare.CloudFlare(token=t)` | `cloudflare.Cloudflare(api_token=t)` | | Client (key) | `CloudFlare.CloudFlare(email, key)` | `cloudflare.Cloudflare(api_email=e, api_key=k)` | | List zones | `cf.zones.get(params={...})` → `list[dict]` | `cf.zones.list(name=n)` → iterable of Zone objects | | Create record | `cf.zones.dns_records.post(zone_id, data={...})` | `cf.dns.records.create(zone_id=id, **data)` | | List records | `cf.zones.dns_records.get(zone_id, params={...})` | `cf.dns.records.list(zone_id=id, type=..., ...)` | | Delete record | `cf.zones.dns_records.delete(zone_id, record_id)` | `cf.dns.records.delete(dns_record_id=rid, zone_id=zid)` | | Exceptions | `CloudFlare.exceptions.CloudFlareAPIError` | `cloudflare.APIStatusError` | ## Test plan - [x] All 20 existing tests pass with updated mocks - [x] Credentials INI file format is unchanged — no user-facing config changes - [x] Live dry-run renewal tested successfully across 5 domains --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Ember <BeigeBox@users.noreply.github.com> Co-authored-by: Brad Warren <bmw@users.noreply.github.com>
This commit is contained in:
parent
9ec4105ff3
commit
c8ebcb49bd
9 changed files with 278 additions and 150 deletions
|
|
@ -5,9 +5,7 @@ from setuptools import setup
|
|||
version = '5.6.0.dev0'
|
||||
|
||||
install_requires = [
|
||||
# for now, do not upgrade to cloudflare>=2.20 to avoid deprecation warnings and the breaking
|
||||
# changes in version 3.0. see https://github.com/certbot/certbot/issues/9938
|
||||
'cloudflare>=2.19, <2.20',
|
||||
'cloudflare>=4.0',
|
||||
]
|
||||
|
||||
if os.environ.get('SNAP_BUILD'):
|
||||
|
|
|
|||
|
|
@ -1,11 +1,21 @@
|
|||
"""DNS Authenticator for Cloudflare."""
|
||||
import logging
|
||||
import warnings
|
||||
from typing import Any
|
||||
from typing import Callable
|
||||
from typing import Literal
|
||||
from typing import Optional
|
||||
from typing import cast
|
||||
from typing import TypedDict
|
||||
|
||||
import CloudFlare
|
||||
# cloudflare 4.x includes a pydantic v1 compatibility shim that emits a
|
||||
# UserWarning on Python 3.14+. Suppress it here so that this internal-detail
|
||||
# warning is not shown to users during plugin discovery. In our test suite it
|
||||
# is filtered out via pytest.ini so it does not affect filterwarnings=error.
|
||||
with warnings.catch_warnings():
|
||||
warnings.filterwarnings('ignore', message='Core Pydantic V1 functionality',
|
||||
category=UserWarning)
|
||||
import cloudflare
|
||||
from cloudflare.types.zones import Zone
|
||||
|
||||
from certbot import errors
|
||||
from certbot.plugins import dns_common
|
||||
|
|
@ -95,15 +105,15 @@ class _CloudflareClient:
|
|||
api_token: Optional[str] = None) -> None:
|
||||
if email:
|
||||
# If an email was specified, we're using an email/key combination and not a token.
|
||||
# We can't use named arguments in this case, as it would break compatibility with
|
||||
# the Cloudflare library since version 2.10.1, as the `token` argument was used for
|
||||
# tokens and keys alike and the `key` argument did not exist in earlier versions.
|
||||
self.cf = CloudFlare.CloudFlare(email, api_key)
|
||||
# We use named arguments here to match the cloudflare 4.x SDK's explicit parameter
|
||||
# names (api_email and api_key), which correspond to the Global API Key credentials
|
||||
# found in the Cloudflare dashboard under My Profile > API Tokens.
|
||||
self.cf = cloudflare.Cloudflare(api_email=email, api_key=api_key)
|
||||
else:
|
||||
# If no email was specified, we're using just a token. Let's use the named argument
|
||||
# for simplicity, which is compatible with all (current) versions of the Cloudflare
|
||||
# library.
|
||||
self.cf = CloudFlare.CloudFlare(token=api_token)
|
||||
# If no email was specified, we're using just an API token. We use the named argument
|
||||
# for clarity. API Tokens are the recommended authentication method as they support
|
||||
# fine-grained permissions scoped to specific zones and operations.
|
||||
self.cf = cloudflare.Cloudflare(api_token=api_token)
|
||||
|
||||
def add_txt_record(self, domain: str, record_name: str, record_content: str,
|
||||
record_ttl: int) -> None:
|
||||
|
|
@ -119,24 +129,30 @@ class _CloudflareClient:
|
|||
|
||||
zone_id = self._find_zone_id(domain)
|
||||
|
||||
data = {'type': 'TXT',
|
||||
'name': record_name,
|
||||
'content': record_content,
|
||||
'ttl': record_ttl}
|
||||
data: _RecordData = {
|
||||
'type': 'TXT',
|
||||
'name': record_name,
|
||||
'content': record_content,
|
||||
'ttl': record_ttl,
|
||||
}
|
||||
|
||||
try:
|
||||
logger.debug('Attempting to add record to zone %s: %s', zone_id, data)
|
||||
self.cf.zones.dns_records.post(zone_id, data=data) # zones | pylint: disable=no-member
|
||||
except CloudFlare.exceptions.CloudFlareAPIError as e:
|
||||
code = int(e)
|
||||
self.cf.dns.records.create(zone_id=zone_id, **data)
|
||||
except cloudflare.APIStatusError as e:
|
||||
code = _cf_error_code(e)
|
||||
hint = None
|
||||
|
||||
if code == 1009:
|
||||
hint = 'Does your API token have "Zone:DNS:Edit" permissions?'
|
||||
|
||||
logger.error('Encountered CloudFlareAPIError adding TXT record: %d %s', e, e)
|
||||
logger.error('Encountered Cloudflare API error adding TXT record: %s', e)
|
||||
raise errors.PluginError('Error communicating with the Cloudflare API: {0}{1}'
|
||||
.format(e, ' ({0})'.format(hint) if hint else ''))
|
||||
except cloudflare.APIConnectionError as e:
|
||||
logger.error('Network error talking to the Cloudflare API: %s', e)
|
||||
raise errors.PluginError('Network error communicating with the Cloudflare API '
|
||||
'while adding a TXT record: {0}'.format(e))
|
||||
|
||||
record_id = self._find_txt_record_id(zone_id, record_name, record_content)
|
||||
logger.debug('Successfully added TXT record with record_id: %s', record_id)
|
||||
|
|
@ -159,52 +175,48 @@ class _CloudflareClient:
|
|||
zone_id = self._find_zone_id(domain)
|
||||
except errors.PluginError as e:
|
||||
logger.debug('Encountered error finding zone_id during deletion: %s', e)
|
||||
logger.debug('Zone not found; no cleanup needed.')
|
||||
return
|
||||
|
||||
if zone_id:
|
||||
record_id = self._find_txt_record_id(zone_id, record_name, record_content)
|
||||
if record_id:
|
||||
try:
|
||||
# zones | pylint: disable=no-member
|
||||
self.cf.zones.dns_records.delete(zone_id, record_id)
|
||||
logger.debug('Successfully deleted TXT record.')
|
||||
except CloudFlare.exceptions.CloudFlareAPIError as e:
|
||||
logger.warning('Encountered CloudFlareAPIError deleting TXT record: %s', e)
|
||||
else:
|
||||
logger.debug('TXT record not found; no cleanup needed.')
|
||||
record_id = self._find_txt_record_id(zone_id, record_name, record_content)
|
||||
if record_id:
|
||||
try:
|
||||
self.cf.dns.records.delete(dns_record_id=record_id, zone_id=zone_id)
|
||||
logger.debug('Successfully deleted TXT record.')
|
||||
except cloudflare.APIStatusError as e:
|
||||
logger.warning('Encountered Cloudflare API error deleting TXT record: %s', e)
|
||||
except cloudflare.APIConnectionError as e:
|
||||
logger.warning('Network error deleting TXT record from Cloudflare: %s', e)
|
||||
else:
|
||||
logger.debug('Zone not found; no cleanup needed.')
|
||||
logger.debug('TXT record not found; no cleanup needed.')
|
||||
|
||||
def _find_zone_id(self, domain: str) -> str:
|
||||
"""
|
||||
Find the zone_id for a given domain.
|
||||
|
||||
:param str domain: The domain for which to find the zone_id.
|
||||
:returns: The zone_id, if found.
|
||||
:returns: The zone_id for the first matching zone that has a non-empty
|
||||
identifier. A zone with an empty/invalid id is treated as if no zone
|
||||
were found, so this method never returns an empty string.
|
||||
:rtype: str
|
||||
:raises certbot.errors.PluginError: if no zone_id is found.
|
||||
"""
|
||||
|
||||
zone_name_guesses = dns_common.base_domain_name_guesses(domain)
|
||||
zones: list[dict[str, Any]] = []
|
||||
zone: Zone | None = None
|
||||
code = msg = None
|
||||
|
||||
for zone_name in zone_name_guesses:
|
||||
params = {'name': zone_name,
|
||||
'per_page': 1}
|
||||
|
||||
try:
|
||||
zones = self.cf.zones.get(params=params) # zones | pylint: disable=no-member
|
||||
except CloudFlare.exceptions.CloudFlareAPIError as e:
|
||||
code = int(e)
|
||||
zone = next(iter(self.cf.zones.list(name=zone_name, per_page=1)), None)
|
||||
except cloudflare.APIStatusError as e:
|
||||
code = _cf_error_code(e)
|
||||
msg = str(e)
|
||||
hint = None
|
||||
|
||||
if code == 6003:
|
||||
hint = ('Did you copy your entire API token/key? To use Cloudflare tokens, '
|
||||
'you\'ll need the python package cloudflare>=2.3.1.{}'
|
||||
.format(' This certbot is running cloudflare ' + str(CloudFlare.__version__)
|
||||
if hasattr(CloudFlare, '__version__') else ''))
|
||||
hint = ('Did you copy your entire API token/key? '
|
||||
'See {} to manage your API tokens.'.format(ACCOUNT_URL))
|
||||
elif code == 9103:
|
||||
hint = 'Did you enter the correct email address and Global key?'
|
||||
elif code == 9109:
|
||||
|
|
@ -215,13 +227,19 @@ class _CloudflareClient:
|
|||
'that you have supplied valid Cloudflare API credentials. ({2})'
|
||||
.format(code, msg, hint))
|
||||
else:
|
||||
logger.debug('Unrecognised CloudFlareAPIError while finding zone_id: %d %s. '
|
||||
'Continuing with next zone guess...', e, e)
|
||||
logger.debug('Unrecognised Cloudflare API error while finding zone_id: %s. '
|
||||
'Continuing with next zone guess...', e)
|
||||
except cloudflare.APIConnectionError as e:
|
||||
raise errors.PluginError('Network error contacting the Cloudflare API while '
|
||||
'looking up the zone for {0}: {1}'.format(domain, e))
|
||||
|
||||
if zones:
|
||||
zone_id: str = zones[0]['id']
|
||||
logger.debug('Found zone_id of %s for %s using name %s', zone_id, domain, zone_name)
|
||||
return zone_id
|
||||
if zone:
|
||||
zone_id = zone.id
|
||||
if zone_id:
|
||||
logger.debug('Found zone_id of %s for %s using name %s',
|
||||
zone_id, domain, zone_name)
|
||||
return zone_id
|
||||
break # Found a zone but it has no usable ID; stop searching
|
||||
|
||||
if msg is not None:
|
||||
if 'com.cloudflare.api.account.zone.list' in msg:
|
||||
|
|
@ -252,20 +270,38 @@ class _CloudflareClient:
|
|||
:rtype: str
|
||||
"""
|
||||
|
||||
params = {'type': 'TXT',
|
||||
'name': record_name,
|
||||
'content': record_content,
|
||||
'per_page': 1}
|
||||
try:
|
||||
# zones | pylint: disable=no-member
|
||||
records = self.cf.zones.dns_records.get(zone_id, params=params)
|
||||
except CloudFlare.exceptions.CloudFlareAPIError as e:
|
||||
logger.debug('Encountered CloudFlareAPIError getting TXT record_id: %s', e)
|
||||
records = list(self.cf.dns.records.list(
|
||||
zone_id=zone_id, type='TXT', name={'exact': record_name},
|
||||
content={'exact': record_content}, per_page=1))
|
||||
except cloudflare.APIStatusError as e:
|
||||
logger.debug('Encountered Cloudflare API error getting TXT record_id: %s', e)
|
||||
records = []
|
||||
except cloudflare.APIConnectionError as e:
|
||||
logger.debug('Network error getting TXT record_id from Cloudflare: %s', e)
|
||||
records = []
|
||||
|
||||
if records:
|
||||
# Cleanup is returning the system to the state we found it. If, for some reason,
|
||||
# there are multiple matching records, we only delete one because we only added one.
|
||||
return cast(str, records[0]['id'])
|
||||
return records[0].id
|
||||
logger.debug('Unable to find TXT record.')
|
||||
return None
|
||||
|
||||
|
||||
class _RecordData(TypedDict):
|
||||
"""Offers type hints for dictionaries of Cloudflare API parameters."""
|
||||
|
||||
type: Literal['TXT']
|
||||
name: str
|
||||
content: str
|
||||
ttl: int
|
||||
|
||||
|
||||
def _cf_error_code(e: cloudflare.APIStatusError) -> int | None:
|
||||
"""Extract the first Cloudflare error code from an API error response."""
|
||||
try:
|
||||
body = e.response.json()
|
||||
return int(body['errors'][0]['code'])
|
||||
except (ValueError, KeyError, IndexError, TypeError): # pragma: no cover
|
||||
return None
|
||||
|
|
|
|||
|
|
@ -2,9 +2,10 @@
|
|||
|
||||
import sys
|
||||
import unittest
|
||||
from typing import Any
|
||||
from unittest import mock
|
||||
|
||||
import CloudFlare
|
||||
import cloudflare
|
||||
import pytest
|
||||
|
||||
from certbot import errors
|
||||
|
|
@ -13,7 +14,23 @@ from certbot.plugins import dns_test_common
|
|||
from certbot.plugins.dns_test_common import DOMAIN
|
||||
from certbot.tests import util as test_util
|
||||
|
||||
API_ERROR = CloudFlare.exceptions.CloudFlareAPIError(1000, '', '')
|
||||
|
||||
def _make_api_error(cf_code: int, msg: str = '', http_status: int = 400
|
||||
) -> cloudflare.APIStatusError:
|
||||
"""Build a cloudflare.APIStatusError with a Cloudflare error code in the body."""
|
||||
body: Any = {'success': False, 'errors': [{'code': cf_code, 'message': msg}]}
|
||||
response = mock.Mock(status_code=http_status, request=mock.Mock())
|
||||
response.json.return_value = body
|
||||
return cloudflare.APIStatusError(message=msg or str(cf_code), response=response, body=body)
|
||||
|
||||
|
||||
def _make_connection_error(msg: str = 'Connection error.') -> cloudflare.APIConnectionError:
|
||||
"""Build a cloudflare.APIConnectionError (e.g. transient network failure)."""
|
||||
return cloudflare.APIConnectionError(message=msg, request=mock.Mock())
|
||||
|
||||
|
||||
API_ERROR = _make_api_error(1000)
|
||||
CONNECTION_ERROR = _make_connection_error()
|
||||
|
||||
API_TOKEN = 'an-api-token'
|
||||
|
||||
|
|
@ -96,13 +113,43 @@ class AuthenticatorTest(test_util.TempDirTestCase, dns_test_common.BaseAuthentic
|
|||
with pytest.raises(errors.PluginError):
|
||||
self.auth.perform([self.achall])
|
||||
|
||||
def test_get_cloudflare_client_with_api_token(self):
|
||||
from certbot_dns_cloudflare._internal.dns_cloudflare import Authenticator
|
||||
from certbot_dns_cloudflare._internal.dns_cloudflare import _CloudflareClient
|
||||
mock_auth = mock.MagicMock()
|
||||
mock_auth.credentials.conf.return_value = API_TOKEN
|
||||
client = Authenticator._get_cloudflare_client(mock_auth)
|
||||
self.assertIsInstance(client, _CloudflareClient)
|
||||
|
||||
def test_get_cloudflare_client_with_email_key(self):
|
||||
from certbot_dns_cloudflare._internal.dns_cloudflare import Authenticator
|
||||
from certbot_dns_cloudflare._internal.dns_cloudflare import _CloudflareClient
|
||||
mock_auth = mock.MagicMock()
|
||||
mock_auth.credentials.conf.side_effect = lambda k: None if k == 'api-token' else 'some_value'
|
||||
client = Authenticator._get_cloudflare_client(mock_auth)
|
||||
self.assertIsInstance(client, _CloudflareClient)
|
||||
|
||||
|
||||
def _mock_zone(zone_id: str | None) -> mock.MagicMock:
|
||||
"""Create a mock zone object with an .id attribute."""
|
||||
zone = mock.MagicMock()
|
||||
zone.id = zone_id
|
||||
return zone
|
||||
|
||||
|
||||
def _mock_record(record_id: str | None) -> mock.MagicMock:
|
||||
"""Create a mock DNS record object with an .id attribute."""
|
||||
record = mock.MagicMock()
|
||||
record.id = record_id
|
||||
return record
|
||||
|
||||
|
||||
class CloudflareClientTest(unittest.TestCase):
|
||||
record_name = "foo"
|
||||
record_content = "bar"
|
||||
record_ttl = 42
|
||||
zone_id = 1
|
||||
record_id = 2
|
||||
zone_id = "zone-id-1"
|
||||
record_id = "record-id-2"
|
||||
|
||||
def setUp(self):
|
||||
from certbot_dns_cloudflare._internal.dns_cloudflare import _CloudflareClient
|
||||
|
|
@ -113,119 +160,145 @@ class CloudflareClientTest(unittest.TestCase):
|
|||
self.cloudflare_client.cf = self.cf
|
||||
|
||||
def test_add_txt_record(self):
|
||||
self.cf.zones.get.return_value = [{'id': self.zone_id}]
|
||||
self.cf.zones.list.return_value = [_mock_zone(self.zone_id)]
|
||||
|
||||
self.cloudflare_client.add_txt_record(DOMAIN, self.record_name, self.record_content,
|
||||
self.record_ttl)
|
||||
|
||||
self.cf.zones.dns_records.post.assert_called_with(self.zone_id, data=mock.ANY)
|
||||
|
||||
post_data = self.cf.zones.dns_records.post.call_args[1]['data']
|
||||
|
||||
assert 'TXT' == post_data['type']
|
||||
assert self.record_name == post_data['name']
|
||||
assert self.record_content == post_data['content']
|
||||
assert self.record_ttl == post_data['ttl']
|
||||
self.cf.dns.records.create.assert_called_with(
|
||||
zone_id=self.zone_id, type='TXT', name=self.record_name,
|
||||
content=self.record_content, ttl=self.record_ttl)
|
||||
|
||||
def test_add_txt_record_error(self):
|
||||
self.cf.zones.get.return_value = [{'id': self.zone_id}]
|
||||
self.cf.zones.list.return_value = [_mock_zone(self.zone_id)]
|
||||
|
||||
self.cf.zones.dns_records.post.side_effect = CloudFlare.exceptions.CloudFlareAPIError(1009, '', '')
|
||||
self.cf.dns.records.create.side_effect = _make_api_error(1009)
|
||||
|
||||
with pytest.raises(errors.PluginError):
|
||||
self.cloudflare_client.add_txt_record(DOMAIN, self.record_name, self.record_content, self.record_ttl)
|
||||
self.cloudflare_client.add_txt_record(DOMAIN, self.record_name, self.record_content,
|
||||
self.record_ttl)
|
||||
|
||||
def test_add_txt_record_error_during_zone_lookup(self):
|
||||
self.cf.zones.get.side_effect = API_ERROR
|
||||
self.cf.zones.list.side_effect = API_ERROR
|
||||
|
||||
with pytest.raises(errors.PluginError):
|
||||
self.cloudflare_client.add_txt_record(DOMAIN, self.record_name, self.record_content, self.record_ttl)
|
||||
self.cloudflare_client.add_txt_record(DOMAIN, self.record_name, self.record_content,
|
||||
self.record_ttl)
|
||||
|
||||
def test_add_txt_record_zone_not_found(self):
|
||||
self.cf.zones.get.return_value = []
|
||||
self.cf.zones.list.return_value = []
|
||||
|
||||
with pytest.raises(errors.PluginError):
|
||||
self.cloudflare_client.add_txt_record(DOMAIN, self.record_name, self.record_content, self.record_ttl)
|
||||
self.cloudflare_client.add_txt_record(DOMAIN, self.record_name, self.record_content,
|
||||
self.record_ttl)
|
||||
|
||||
def test_add_txt_record_connection_error_on_create(self):
|
||||
self.cf.zones.list.return_value = [_mock_zone(self.zone_id)]
|
||||
self.cf.dns.records.create.side_effect = CONNECTION_ERROR
|
||||
|
||||
with pytest.raises(errors.PluginError, match='Network error'):
|
||||
self.cloudflare_client.add_txt_record(DOMAIN, self.record_name, self.record_content,
|
||||
self.record_ttl)
|
||||
|
||||
def test_add_txt_record_connection_error_during_zone_lookup(self):
|
||||
self.cf.zones.list.side_effect = CONNECTION_ERROR
|
||||
|
||||
with pytest.raises(errors.PluginError, match='Network error'):
|
||||
self.cloudflare_client.add_txt_record(DOMAIN, self.record_name, self.record_content,
|
||||
self.record_ttl)
|
||||
|
||||
def test_add_txt_record_bad_creds(self):
|
||||
self.cf.zones.get.side_effect = CloudFlare.exceptions.CloudFlareAPIError(6003, '', '')
|
||||
self.cf.zones.list.side_effect = _make_api_error(6003)
|
||||
with pytest.raises(errors.PluginError):
|
||||
self.cloudflare_client.add_txt_record(DOMAIN, self.record_name, self.record_content, self.record_ttl)
|
||||
self.cloudflare_client.add_txt_record(DOMAIN, self.record_name, self.record_content,
|
||||
self.record_ttl)
|
||||
|
||||
self.cf.zones.get.side_effect = CloudFlare.exceptions.CloudFlareAPIError(9103, '', '')
|
||||
self.cf.zones.list.side_effect = _make_api_error(9103)
|
||||
with pytest.raises(errors.PluginError):
|
||||
self.cloudflare_client.add_txt_record(DOMAIN, self.record_name, self.record_content, self.record_ttl)
|
||||
self.cloudflare_client.add_txt_record(DOMAIN, self.record_name, self.record_content,
|
||||
self.record_ttl)
|
||||
|
||||
self.cf.zones.get.side_effect = CloudFlare.exceptions.CloudFlareAPIError(9109, '', '')
|
||||
self.cf.zones.list.side_effect = _make_api_error(9109)
|
||||
with pytest.raises(errors.PluginError):
|
||||
self.cloudflare_client.add_txt_record(DOMAIN, self.record_name, self.record_content, self.record_ttl)
|
||||
self.cloudflare_client.add_txt_record(DOMAIN, self.record_name, self.record_content,
|
||||
self.record_ttl)
|
||||
|
||||
self.cf.zones.get.side_effect = CloudFlare.exceptions.CloudFlareAPIError(0, 'com.cloudflare.api.account.zone.list', '')
|
||||
self.cf.zones.list.side_effect = _make_api_error(0, 'com.cloudflare.api.account.zone.list')
|
||||
with pytest.raises(errors.PluginError):
|
||||
self.cloudflare_client.add_txt_record(DOMAIN, self.record_name, self.record_content, self.record_ttl)
|
||||
self.cloudflare_client.add_txt_record(DOMAIN, self.record_name, self.record_content,
|
||||
self.record_ttl)
|
||||
|
||||
def test_del_txt_record(self):
|
||||
self.cf.zones.get.return_value = [{'id': self.zone_id}]
|
||||
self.cf.zones.dns_records.get.return_value = [{'id': self.record_id}]
|
||||
self.cf.zones.list.return_value = [_mock_zone(self.zone_id)]
|
||||
self.cf.dns.records.list.return_value = [_mock_record(self.record_id)]
|
||||
|
||||
self.cloudflare_client.del_txt_record(DOMAIN, self.record_name, self.record_content)
|
||||
|
||||
expected = [mock.call.zones.get(params=mock.ANY),
|
||||
mock.call.zones.dns_records.get(self.zone_id, params=mock.ANY),
|
||||
mock.call.zones.dns_records.delete(self.zone_id, self.record_id)]
|
||||
|
||||
assert expected == self.cf.mock_calls
|
||||
|
||||
get_data = self.cf.zones.dns_records.get.call_args[1]['params']
|
||||
|
||||
assert 'TXT' == get_data['type']
|
||||
assert self.record_name == get_data['name']
|
||||
assert self.record_content == get_data['content']
|
||||
self.cf.zones.list.assert_called_once()
|
||||
self.cf.dns.records.list.assert_called_once_with(
|
||||
zone_id=self.zone_id, type='TXT', name={'exact': self.record_name},
|
||||
content={'exact': self.record_content}, per_page=1)
|
||||
self.cf.dns.records.delete.assert_called_once_with(
|
||||
dns_record_id=self.record_id, zone_id=self.zone_id)
|
||||
|
||||
def test_del_txt_record_error_during_zone_lookup(self):
|
||||
self.cf.zones.get.side_effect = API_ERROR
|
||||
self.cf.zones.list.side_effect = API_ERROR
|
||||
|
||||
self.cloudflare_client.del_txt_record(DOMAIN, self.record_name, self.record_content)
|
||||
|
||||
def test_del_txt_record_error_during_delete(self):
|
||||
self.cf.zones.get.return_value = [{'id': self.zone_id}]
|
||||
self.cf.zones.dns_records.get.return_value = [{'id': self.record_id}]
|
||||
self.cf.zones.dns_records.delete.side_effect = API_ERROR
|
||||
self.cf.zones.list.return_value = [_mock_zone(self.zone_id)]
|
||||
self.cf.dns.records.list.return_value = [_mock_record(self.record_id)]
|
||||
self.cf.dns.records.delete.side_effect = API_ERROR
|
||||
|
||||
self.cloudflare_client.del_txt_record(DOMAIN, self.record_name, self.record_content)
|
||||
expected = [mock.call.zones.get(params=mock.ANY),
|
||||
mock.call.zones.dns_records.get(self.zone_id, params=mock.ANY),
|
||||
mock.call.zones.dns_records.delete(self.zone_id, self.record_id)]
|
||||
|
||||
assert expected == self.cf.mock_calls
|
||||
self.cf.dns.records.delete.assert_called_once_with(
|
||||
dns_record_id=self.record_id, zone_id=self.zone_id)
|
||||
|
||||
def test_del_txt_record_error_during_get(self):
|
||||
self.cf.zones.get.return_value = [{'id': self.zone_id}]
|
||||
self.cf.zones.dns_records.get.side_effect = API_ERROR
|
||||
self.cf.zones.list.return_value = [_mock_zone(self.zone_id)]
|
||||
self.cf.dns.records.list.side_effect = API_ERROR
|
||||
|
||||
self.cloudflare_client.del_txt_record(DOMAIN, self.record_name, self.record_content)
|
||||
expected = [mock.call.zones.get(params=mock.ANY),
|
||||
mock.call.zones.dns_records.get(self.zone_id, params=mock.ANY)]
|
||||
|
||||
assert expected == self.cf.mock_calls
|
||||
self.cf.dns.records.list.assert_called_once()
|
||||
self.cf.dns.records.delete.assert_not_called()
|
||||
|
||||
def test_del_txt_record_no_record(self):
|
||||
self.cf.zones.get.return_value = [{'id': self.zone_id}]
|
||||
self.cf.zones.dns_records.get.return_value = []
|
||||
self.cf.zones.list.return_value = [_mock_zone(self.zone_id)]
|
||||
self.cf.dns.records.list.return_value = []
|
||||
|
||||
self.cloudflare_client.del_txt_record(DOMAIN, self.record_name, self.record_content)
|
||||
expected = [mock.call.zones.get(params=mock.ANY),
|
||||
mock.call.zones.dns_records.get(self.zone_id, params=mock.ANY)]
|
||||
|
||||
assert expected == self.cf.mock_calls
|
||||
self.cf.dns.records.list.assert_called_once()
|
||||
self.cf.dns.records.delete.assert_not_called()
|
||||
|
||||
def test_del_txt_record_no_zone(self):
|
||||
self.cf.zones.get.return_value = [{'id': None}]
|
||||
self.cf.zones.list.return_value = [_mock_zone(None)]
|
||||
|
||||
self.cloudflare_client.del_txt_record(DOMAIN, self.record_name, self.record_content)
|
||||
expected = [mock.call.zones.get(params=mock.ANY)]
|
||||
|
||||
assert expected == self.cf.mock_calls
|
||||
self.cf.zones.list.assert_called_once()
|
||||
|
||||
def test_del_txt_record_connection_error_on_delete(self):
|
||||
self.cf.zones.list.return_value = [_mock_zone(self.zone_id)]
|
||||
self.cf.dns.records.list.return_value = [_mock_record(self.record_id)]
|
||||
self.cf.dns.records.delete.side_effect = CONNECTION_ERROR
|
||||
|
||||
self.cloudflare_client.del_txt_record(DOMAIN, self.record_name, self.record_content)
|
||||
|
||||
self.cf.dns.records.delete.assert_called_once_with(
|
||||
dns_record_id=self.record_id, zone_id=self.zone_id)
|
||||
|
||||
def test_del_txt_record_connection_error_on_get(self):
|
||||
self.cf.zones.list.return_value = [_mock_zone(self.zone_id)]
|
||||
self.cf.dns.records.list.side_effect = CONNECTION_ERROR
|
||||
|
||||
self.cloudflare_client.del_txt_record(DOMAIN, self.record_name, self.record_content)
|
||||
|
||||
self.cf.dns.records.list.assert_called_once()
|
||||
self.cf.dns.records.delete.assert_not_called()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ install_requires = [
|
|||
'ConfigArgParse>=1.5.3',
|
||||
'configobj>=5.0.6',
|
||||
'cryptography>=43.0.0',
|
||||
'distro>=1.0.1',
|
||||
'distro>=1.7.0',
|
||||
'importlib_metadata>=8.6.1; python_version < "3.10"',
|
||||
'josepy>=2.0.0',
|
||||
'parsedatetime>=2.6',
|
||||
|
|
|
|||
1
newsfragments/10587.changed
Normal file
1
newsfragments/10587.changed
Normal file
|
|
@ -0,0 +1 @@
|
|||
certbot now requires version 1.7+ of the library distro and certbot-dns-cloudflare requires 4.0+ of the Cloudflare Python library.
|
||||
|
|
@ -13,6 +13,14 @@
|
|||
# 1) In v2.28.0, google-api-core added an annoying message that the current python's EOL
|
||||
# is coming up. We deprecate python versions on schedule, so mostly this is just an
|
||||
# annoyance for our own tests, and can probably be silenced forever.
|
||||
# 2) cloudflare 4.x imports pydantic.v1 for compatibility, which emits a
|
||||
# UserWarning on Python 3.14+. Cloudflare plan to add official 3.13/3.14
|
||||
# support in their upcoming v5 release; track progress (and remove this
|
||||
# entry once released) via https://github.com/cloudflare/cloudflare-python/issues/2679
|
||||
# When this pytest.ini entry is removed, the warnings filter at the top of
|
||||
# certbot-dns-cloudflare/src/certbot_dns_cloudflare/_internal/dns_cloudflare.py
|
||||
# should be removed as well.
|
||||
filterwarnings =
|
||||
error
|
||||
ignore:You are using a Python version (.*) which Google will stop supporting:FutureWarning
|
||||
ignore:Core Pydantic V1 functionality:UserWarning
|
||||
|
|
|
|||
|
|
@ -1,44 +1,47 @@
|
|||
# This file was generated by tools/pinning/oldest/repin.sh and can be updated using
|
||||
# that script.
|
||||
annotated-types==0.7.0 ; python_version == "3.10"
|
||||
anyio==4.12.1 ; python_version == "3.10"
|
||||
apacheconfig==0.3.2 ; python_version == "3.10"
|
||||
asn1crypto==0.24.0 ; python_version == "3.10"
|
||||
astroid==4.0.3 ; python_version == "3.10"
|
||||
attrs==25.4.0 ; python_version == "3.10"
|
||||
astroid==4.0.4 ; python_version == "3.10"
|
||||
beautifulsoup4==4.14.3 ; python_version == "3.10"
|
||||
boto3==1.20.34 ; python_version == "3.10"
|
||||
botocore==1.23.34 ; python_version == "3.10"
|
||||
cachetools==5.5.2 ; python_version == "3.10"
|
||||
certifi==2026.1.4 ; python_version == "3.10"
|
||||
certifi==2026.2.25 ; python_version == "3.10"
|
||||
cffi==1.14.1 ; python_version == "3.10"
|
||||
chardet==3.0.4 ; python_version == "3.10"
|
||||
cloudflare==2.19.0 ; python_version == "3.10"
|
||||
cloudflare==4.0.0 ; python_version == "3.10"
|
||||
colorama==0.4.6 ; (sys_platform == "win32" or platform_system == "Windows") and python_version == "3.10"
|
||||
configargparse==1.5.3 ; python_version == "3.10"
|
||||
configobj==5.0.6 ; python_version == "3.10"
|
||||
coverage==7.13.3 ; python_version == "3.10"
|
||||
coverage==7.13.4 ; python_version == "3.10"
|
||||
cryptography==43.0.0 ; python_version == "3.10"
|
||||
cython==0.29.37 ; python_version == "3.10"
|
||||
dill==0.4.1 ; python_version == "3.10"
|
||||
distlib==0.4.0 ; python_version == "3.10"
|
||||
distro==1.0.1 ; python_version == "3.10"
|
||||
distro==1.7.0 ; python_version == "3.10"
|
||||
dns-lexicon==3.15.1 ; python_version == "3.10"
|
||||
dnspython==2.6.1 ; python_version == "3.10"
|
||||
exceptiongroup==1.3.1 ; python_version == "3.10"
|
||||
execnet==2.1.2 ; python_version == "3.10"
|
||||
filelock==3.20.3 ; python_version == "3.10"
|
||||
filelock==3.25.0 ; python_version == "3.10"
|
||||
funcsigs==0.4 ; python_version == "3.10"
|
||||
google-api-python-client==1.6.5 ; python_version == "3.10"
|
||||
google-auth==2.16.0 ; python_version == "3.10"
|
||||
h11==0.16.0 ; python_version == "3.10"
|
||||
httpcore==1.0.9 ; python_version == "3.10"
|
||||
httplib2==0.9.2 ; python_version == "3.10"
|
||||
idna==2.6 ; python_version == "3.10"
|
||||
httpx==0.28.1 ; python_version == "3.10"
|
||||
idna==2.8 ; python_version == "3.10"
|
||||
iniconfig==2.3.0 ; python_version == "3.10"
|
||||
ipaddress==1.0.16 ; python_version == "3.10"
|
||||
isort==7.0.0 ; python_version == "3.10"
|
||||
isort==8.0.1 ; python_version == "3.10"
|
||||
jmespath==0.10.0 ; python_version == "3.10"
|
||||
josepy==2.2.0 ; python_version == "3.10"
|
||||
jsonlines==4.0.0 ; python_version == "3.10"
|
||||
jsonpickle==4.1.1 ; python_version == "3.10"
|
||||
librt==0.7.8 ; python_version == "3.10" and platform_python_implementation != "PyPy"
|
||||
librt==0.8.1 ; python_version == "3.10" and platform_python_implementation != "PyPy"
|
||||
mccabe==0.7.0 ; python_version == "3.10"
|
||||
mypy-extensions==1.1.0 ; python_version == "3.10"
|
||||
mypy==1.19.1 ; python_version == "3.10"
|
||||
|
|
@ -48,16 +51,18 @@ packaging==26.0 ; python_version == "3.10"
|
|||
parsedatetime==2.6 ; python_version == "3.10"
|
||||
pathspec==1.0.4 ; python_version == "3.10"
|
||||
pbr==1.8.0 ; python_version == "3.10"
|
||||
pip==26.0 ; python_version == "3.10"
|
||||
platformdirs==4.5.1 ; python_version == "3.10"
|
||||
pip==26.0.1 ; python_version == "3.10"
|
||||
platformdirs==4.9.2 ; python_version == "3.10"
|
||||
pluggy==1.6.0 ; python_version == "3.10"
|
||||
ply==3.4 ; python_version == "3.10"
|
||||
py==1.11.0 ; python_version == "3.10"
|
||||
pyasn1-modules==0.4.1 ; python_version == "3.10"
|
||||
pyasn1==0.4.8 ; python_version == "3.10"
|
||||
pycparser==2.14 ; python_version == "3.10"
|
||||
pydantic-core==2.41.5 ; python_version == "3.10"
|
||||
pydantic==2.12.5 ; python_version == "3.10"
|
||||
pygments==2.19.2 ; python_version == "3.10"
|
||||
pylint==4.0.4 ; python_version == "3.10"
|
||||
pylint==4.0.5 ; python_version == "3.10"
|
||||
pyopenssl==25.0.0 ; python_version == "3.10"
|
||||
pyotp==2.9.0 ; python_version == "3.10"
|
||||
pyparsing==3.0.0 ; python_version == "3.10"
|
||||
|
|
@ -68,16 +73,18 @@ pytest==9.0.2 ; python_version == "3.10"
|
|||
python-augeas==0.5.0 ; python_version == "3.10"
|
||||
python-dateutil==2.9.0.post0 ; python_version == "3.10"
|
||||
python-digitalocean==1.15.0 ; python_version == "3.10"
|
||||
pytz==2025.2 ; python_version == "3.10"
|
||||
python-discovery==1.1.0 ; python_version == "3.10"
|
||||
pytz==2026.1.post1 ; python_version == "3.10"
|
||||
pywin32==311 ; python_version == "3.10" and sys_platform == "win32"
|
||||
pyyaml==6.0.3 ; python_version == "3.10"
|
||||
requests-file==3.0.1 ; python_version == "3.10"
|
||||
requests==2.25.1 ; python_version == "3.10"
|
||||
rsa==4.9.1 ; python_version == "3.10"
|
||||
ruff==0.15.0 ; python_version == "3.10"
|
||||
ruff==0.15.4 ; python_version == "3.10"
|
||||
s3transfer==0.5.2 ; python_version == "3.10"
|
||||
setuptools==80.10.2 ; python_version == "3.10"
|
||||
setuptools==82.0.0 ; python_version == "3.10"
|
||||
six==1.16.0 ; python_version == "3.10"
|
||||
sniffio==1.3.1 ; python_version == "3.10"
|
||||
soupsieve==2.8.3 ; python_version == "3.10"
|
||||
tldextract==5.3.1 ; python_version == "3.10"
|
||||
tomli==2.4.0 ; python_version == "3.10"
|
||||
|
|
@ -85,14 +92,15 @@ tomlkit==0.14.0 ; python_version == "3.10"
|
|||
tox==3.28.0 ; python_version == "3.10"
|
||||
types-httplib2==0.31.2.20260125 ; python_version == "3.10"
|
||||
types-pyrfc3339==2.0.1.20250825 ; python_version == "3.10"
|
||||
types-python-dateutil==2.9.0.20260124 ; python_version == "3.10"
|
||||
types-python-dateutil==2.9.0.20260302 ; python_version == "3.10"
|
||||
types-pywin32==311.0.0.20251008 ; python_version == "3.10"
|
||||
types-requests==2.31.0.6 ; python_version == "3.10"
|
||||
types-setuptools==80.10.0.20260124 ; python_version == "3.10"
|
||||
types-setuptools==82.0.0.20260210 ; python_version == "3.10"
|
||||
types-urllib3==1.26.25.14 ; python_version == "3.10"
|
||||
typing-extensions==4.15.0 ; python_version == "3.10"
|
||||
typing-inspection==0.4.2 ; python_version == "3.10"
|
||||
uritemplate==3.0.1 ; python_version == "3.10"
|
||||
urllib3==1.26.5 ; python_version == "3.10"
|
||||
uv==0.9.28 ; python_version == "3.10"
|
||||
virtualenv==20.36.1 ; python_version == "3.10"
|
||||
uv==0.10.8 ; python_version == "3.10"
|
||||
virtualenv==21.1.0 ; python_version == "3.10"
|
||||
wheel==0.46.3 ; python_version == "3.10"
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ license = "Apache License 2.0"
|
|||
[tool.poetry.dependencies]
|
||||
# The Python version here should be kept in sync with the one used in our
|
||||
# oldest tests in tox.ini.
|
||||
python = "<3.11 >= 3.10"
|
||||
python = "<3.11,>=3.10"
|
||||
|
||||
# Local dependencies
|
||||
# Any local packages that have dependencies on other local packages must be
|
||||
|
|
@ -59,17 +59,17 @@ boto3 = "1.20.34"
|
|||
botocore = "1.23.34"
|
||||
cffi = "1.14.1"
|
||||
chardet = "3.0.4"
|
||||
cloudflare = "2.19"
|
||||
cloudflare = "4.0.0"
|
||||
configobj = "5.0.6"
|
||||
cryptography = "43.0.0"
|
||||
distro = "1.0.1"
|
||||
distro = "1.7.0"
|
||||
dns-lexicon = "3.15.1"
|
||||
dnspython = "2.6.1"
|
||||
funcsigs = "0.4"
|
||||
google-api-python-client = "1.6.5"
|
||||
google-auth = "2.16.0"
|
||||
httplib2 = "0.9.2"
|
||||
idna = "2.6"
|
||||
idna = "2.8"
|
||||
ipaddress = "1.0.16"
|
||||
ndg-httpsclient = "0.3.2"
|
||||
parsedatetime = "2.6"
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
# https://docs.github.com/en/github/visualizing-repository-data-with-graphs/about-the-dependency-graph#supported-package-ecosystems
|
||||
# for more info.
|
||||
alabaster==1.0.0 ; python_version >= "3.10" and python_version < "4.0"
|
||||
annotated-types==0.7.0 ; python_version >= "3.10" and python_version < "4.0"
|
||||
anyio==4.13.0 ; python_version >= "3.10" and python_version < "4.0"
|
||||
apacheconfig==0.3.2 ; python_version >= "3.10" and python_version < "4.0"
|
||||
astroid==3.3.11 ; python_version >= "3.10" and python_version < "4.0"
|
||||
|
|
@ -27,7 +28,7 @@ cffi==2.0.0 ; python_version >= "3.10" and python_version < "4.0"
|
|||
charset-normalizer==3.4.7 ; python_version >= "3.10" and python_version < "4.0"
|
||||
cleo==2.1.0 ; python_version >= "3.10" and python_version < "4.0"
|
||||
click==8.3.3 ; python_version >= "3.10" and python_version < "4.0"
|
||||
cloudflare==2.19.4 ; python_version >= "3.10" and python_version < "4.0"
|
||||
cloudflare==4.3.1 ; python_version >= "3.10" and python_version < "4.0"
|
||||
colorama==0.4.6 ; python_version >= "3.10" and python_version < "4.0"
|
||||
configargparse==1.7.5 ; python_version >= "3.10" and python_version < "4.0"
|
||||
configobj==5.0.9 ; python_version >= "3.10" and python_version < "4.0"
|
||||
|
|
@ -84,7 +85,6 @@ jeepney==0.9.0 ; python_version >= "3.10" and python_version < "4.0" and sys_pla
|
|||
jinja2==3.1.6 ; python_version >= "3.10" and python_version < "4.0"
|
||||
jmespath==1.1.0 ; python_version >= "3.10" and python_version < "4.0"
|
||||
josepy==2.2.0 ; python_version >= "3.10" and python_version < "4.0"
|
||||
jsonlines==4.0.0 ; python_version >= "3.10" and python_version < "4.0"
|
||||
jsonpickle==4.1.1 ; python_version >= "3.10" and python_version < "4.0"
|
||||
keyring==25.7.0 ; python_version >= "3.10" and python_version < "4.0"
|
||||
markdown-it-py==4.0.0 ; python_version >= "3.10" and python_version < "4.0"
|
||||
|
|
@ -122,6 +122,8 @@ pure-eval==0.2.3 ; python_version >= "3.10" and python_version < "4.0"
|
|||
pyasn1-modules==0.4.2 ; python_version >= "3.10" and python_version < "4.0"
|
||||
pyasn1==0.6.3 ; python_version >= "3.10" and python_version < "4.0"
|
||||
pycparser==3.0 ; python_version >= "3.10" and python_version < "4.0" and implementation_name != "PyPy"
|
||||
pydantic==2.12.5 ; python_version >= "3.10" and python_version < "4.0"
|
||||
pydantic-core==2.41.5 ; python_version >= "3.10" and python_version < "4.0"
|
||||
pygments==2.20.0 ; python_version >= "3.10" and python_version < "4.0"
|
||||
pylint==3.3.3 ; python_version >= "3.10" and python_version < "4.0"
|
||||
pynacl==1.6.2 ; python_version >= "3.10" and python_version < "4.0"
|
||||
|
|
@ -159,6 +161,7 @@ setuptools-rust==1.12.1 ; python_version >= "3.10" and python_version < "4.0"
|
|||
setuptools==82.0.1 ; python_version >= "3.10" and python_version < "4.0"
|
||||
shellingham==1.5.4 ; python_version >= "3.10" and python_version < "4.0"
|
||||
six==1.17.0 ; python_version >= "3.10" and python_version < "4.0"
|
||||
sniffio==1.3.1 ; python_version >= "3.10" and python_version < "4.0"
|
||||
snowballstemmer==3.0.1 ; python_version >= "3.10" and python_version < "4.0"
|
||||
soupsieve==2.8.3 ; python_version >= "3.10" and python_version < "4.0"
|
||||
sphinx-rtd-theme==3.1.0 ; python_version >= "3.10" and python_version < "4.0"
|
||||
|
|
@ -189,6 +192,7 @@ types-pywin32==311.0.0.20260408 ; python_version >= "3.10" and python_version <
|
|||
types-requests==2.33.0.20260408 ; python_version >= "3.10" and python_version < "4.0"
|
||||
types-setuptools==82.0.0.20260408 ; python_version >= "3.10" and python_version < "4.0"
|
||||
typing-extensions==4.15.0 ; python_version >= "3.10" and python_version < "4.0"
|
||||
typing-inspection==0.4.2 ; python_version >= "3.10" and python_version < "4.0"
|
||||
uritemplate==4.2.0 ; python_version >= "3.10" and python_version < "4.0"
|
||||
urllib3==2.6.3 ; python_version >= "3.10" and python_version < "4.0"
|
||||
uv==0.11.8 ; python_version >= "3.10" and python_version < "4.0"
|
||||
|
|
|
|||
Loading…
Reference in a new issue