1
0
mirror of https://github.com/certbot/certbot.git synced 2026-01-26 07:41:33 +03:00

google: use Application Default Credentials where available (#9670)

* google: use Application Default Credentials where available

* Updated custom role documentation
This commit is contained in:
Jawshua
2023-04-21 22:58:18 +01:00
committed by GitHub
parent 399b932a86
commit b0d0a83277
9 changed files with 360 additions and 211 deletions

View File

@@ -123,6 +123,7 @@ Authors
* [James Balazs](https://github.com/jamesbalazs)
* [James Kasten](https://github.com/jdkasten)
* [Jason Grinblat](https://github.com/ptychomancer)
* [Jawshua](https://github.com/jawshua)
* [Jay Faulkner](https://github.com/jayofdoom)
* [J.C. Jones](https://github.com/jcjones)
* [Jeff Hodges](https://github.com/jmhodges)

View File

@@ -14,7 +14,14 @@ Named Arguments
======================================== =====================================
``--dns-google-credentials`` Google Cloud Platform credentials_
JSON file.
(Required - Optional on Google Compute Engine)
(Required if not using `Application Default
Credentials <https://cloud.google.com/docs/authentication/
application-default-credentials>`_.)
``--dns-google-project`` The ID of the Google Cloud project that the Google
Cloud DNS managed zone(s) reside in.
(Default: project that the Google credentials_ belong to)
``--dns-google-propagation-seconds`` The number of seconds to wait for DNS
to propagate before asking the ACME
server to verify the DNS record.
@@ -25,45 +32,37 @@ Named Arguments
Credentials
-----------
Use of this plugin requires Google Cloud Platform API credentials
for an account with the following permissions:
Use of this plugin requires Google Cloud Platform credentials with the ability to modify the Cloud
DNS managed zone(s) for which certificates are being issued.
* ``dns.changes.create``
* ``dns.changes.get``
* ``dns.changes.list``
* ``dns.managedZones.get``
* ``dns.managedZones.list``
* ``dns.resourceRecordSets.create``
* ``dns.resourceRecordSets.delete``
* ``dns.resourceRecordSets.list``
* ``dns.resourceRecordSets.update``
In most cases, configuring credentials for Certbot will require `creating a service account
<https://cloud.google.com/iam/docs/service-accounts-create>`_, and then either `granting permissions
with predefined roles`_ or `granting permissions with custom roles`_ using IAM.
(The closest role is `dns.admin <https://cloud.google.com/dns/docs/
access-control#dns.admin>`_).
If the above permissions are assigned at the `resource level <https://cloud
.google.com/dns/docs/zones/iam-per-resource-zones>`_, the same user must
have, at the PROJECT level, the following permissions:
Providing Credentials
^^^^^^^^^^^^^^^^^^^^^
* ``dns.managedZones.get``
* ``dns.managedZones.list``
The preferred method of providing credentials is to `set up Application Default Credentials
<https://cloud.google.com/docs/authentication/provide-credentials-adc>`_ (ADC) in the environment
that Certbot is running in.
(The closest role is `dns.reader <https://cloud.google.com/dns/docs/
access-control#dns.reader>`_).
If you are running Certbot on Google Cloud then a service account can be assigned directly to most
types of workload, including `Compute Engine VMs <https://cloud.google.com/compute/docs/access/
create-enable-service-accounts-for-instances>`_, `Kubernetes Engine Pods <https://cloud.google.com/
kubernetes-engine/docs/how-to/workload-identity>`_, `Cloud Run jobs <https://cloud.google.com/run
/docs/securing/service-identity>`_, `Cloud Functions <https://cloud.google.com/functions/docs/
securing/function-identity>`_, and `Cloud Builds <https://cloud.google.com/build/docs/securing-
builds/configure-user-specified-service-accounts>`_.
Google provides instructions for `creating a service account <https://developers
.google.com/identity/protocols/OAuth2ServiceAccount#creatinganaccount>`_ and
`information about the required permissions <https://cloud.google.com/dns/access
-control#permissions_and_roles>`_. If you're running on Google Compute Engine,
you can `assign the service account to the instance <https://cloud.google.com/
compute/docs/access/create-enable-service-accounts-for-instances>`_ which
is running certbot. A credentials file is not required in this case, as they
are automatically obtained by certbot through the `metadata service
<https://cloud.google.com/compute/docs/storing-retrieving-metadata>`_ .
If you are not running Certbot on Google Cloud then a credentials file should be provided using the
``--dns-google-credentials`` command-line argument. Google provides documentation for `creating
service account keys <https://cloud.google.com/iam/docs/keys-create-delete#creating>`_, which is the
most common method of using a service account outside of Google Cloud.
.. code-block:: json
:name: credentials.json
:caption: Example credentials file:
:name: credentials-sa.json
:caption: Example service account key file:
{
"type": "service_account",
@@ -78,12 +77,8 @@ are automatically obtained by certbot through the `metadata service
"client_x509_cert_url": "..."
}
The path to this file can be provided interactively or using the
``--dns-google-credentials`` command-line argument. Certbot records the path
to this file for use during renewal, but does not store the file's contents.
.. caution::
You should protect these API credentials as you would a password. Users who
You should protect these credentials as you would a password. Users who
can read this file can use these credentials to issue some types of API calls
on your behalf, limited by the permissions assigned to the account. Users who
can cause Certbot to run using these credentials can complete a ``dns-01``
@@ -97,35 +92,132 @@ file. This warning will be emitted each time Certbot uses the credentials file,
including for renewal, and cannot be silenced except by addressing the issue
(e.g., by using a command like ``chmod 600`` to restrict access to the file).
If you are running Certbot within another cloud platform, a CI platform, or any other platform that
supports issuing OpenID Connect Tokens, then you may also have the option of securely authenticating
with `workload identity federation <https://cloud.google.com/iam/docs/workload-identity-
federation>`_. Instructions are generally available for most platforms, including `AWS or Azure
<https://cloud.google.com/iam/docs/workload-identity-federation-with-other-clouds>`_, `GitHub
Actions <https://cloud.google.com/blog/products/identity-security/enabling-keyless-authentication
-from-github-actions>`_, and `GitLab CI <https://docs.gitlab.com/ee/ci/cloud_services/
google_cloud/>`_.
Granting Permissions with Predefined Roles
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
The simplest method of granting the required permissions to the user or service account that Certbot
is authenticating with is to use either of these predefined role strategies:
* `dns.admin <https://cloud.google.com/dns/docs/access-control#dns.admin>`_ against the *DNS
zone(s)* that Certbot will be issuing certificates for.
* `dns.reader <https://cloud.google.com/dns/docs/access-control#dns.reader>`_ against the *project*
containing the relevant DNS zones.
*or*
* `dns.admin <https://cloud.google.com/dns/docs/access-control#dns.admin>`_ against the *project*
containing the relevant DNS zones
For instructions on how to grant roles, please read the Google provided documentation for `granting
access roles against a project <https://cloud.google.com/iam/docs/granting-changing-revoking-access
#single-role>`_ and `granting access roles against zones <https://cloud.google.com/dns/docs/zones/
iam-per-resource-zones#set_access_control_policy_for_a_specific_resource>`_.
.. caution::
Granting the ``dns.admin`` role at the project level can present a significant security risk. It
provides full administrative access to all DNS zones within the project, granting the ability to
perform any action up to and including deleting all zones within a project.
Granting Permissions with Custom Roles
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Custom roles are an alternative to predefined roles that provide the ability to define fine grained
permission sets for specific use cases. They should generally be used when it is desirable to adhere
to the principle of least privilege, such as within production or other security sensitive
workloads.
The following is an example strategy for granting granular permissions to Certbot using custom
roles. If you are not already familiar with how to do so, Google provides documentation for
`creating a custom IAM role <https://cloud.google.com/iam/docs/creating-custom-roles#creating>`_.
Firstly, create a custom role containing the permissions required to make DNS record updates. We
suggest naming the custom role ``Certbot - Zone Editor`` with the ID ``certbot.zoneEditor``. The
following permissions are required:
* ``dns.changes.create``
* ``dns.changes.get``
* ``dns.changes.list``
* ``dns.resourceRecordSets.create``
* ``dns.resourceRecordSets.delete``
* ``dns.resourceRecordSets.list``
* ``dns.resourceRecordSets.update``
Next, create a custom role granting Certbot the ability to discover DNS zones. We suggest naming the
custom role ``Certbot - Zone Lister`` with the ID ``certbot.zoneLister``. The following permissions
are required:
* ``dns.managedZones.get``
* ``dns.managedZones.list``
Finally, grant the custom roles to the user or service account that Certbot is authenticating with:
* Grant your custom ``Certbot - Zone Editor`` role against the *DNS zone(s)* that Certbot will be
issuing certificates for.
* Grant your custom ``Certbot - Zone Lister`` role against the *project* containing the relevant DNS
zones.
For instructions on how to grant roles, please read the Google provided documentation for `granting
access roles against a project <https://cloud.google.com/iam/docs/granting-changing-revoking-access
#single-role>`_ and `granting access roles against zones <https://cloud.google.com/dns/docs/zones/
iam-per-resource-zones#set_access_control_policy_for_a_specific_resource>`_.
Examples
--------
.. code-block:: bash
:caption: To acquire a certificate for ``example.com``
:caption: To acquire a certificate for ``example.com``, providing a credentials file
certbot certonly \\
--dns-google \\
--dns-google-credentials ~/.secrets/certbot/google.json \\
-d example.com
.. code-block:: bash
:caption: To acquire a certificate for ``example.com``, where ADC is available and
a credentials file is not required
certbot certonly \\
--dns-google \\
-d example.com
.. code-block:: bash
:caption: To acquire a single certificate for both ``example.com`` and
``www.example.com``
certbot certonly \\
--dns-google \\
--dns-google-credentials ~/.secrets/certbot/google.json \\
-d example.com \\
-d www.example.com
.. code-block:: bash
:caption: To acquire a certificate for ``example.com``, where the managed DNS
zone resides in another Google Cloud project.
certbot certonly \\
--dns-google \\
--dns-google-credentials ~/.secrets/certbot/google-project-test-foo.json \\
--dns-google-project test-bar \\
-d example.com
.. code-block:: bash
:caption: To acquire a certificate for ``example.com``, waiting 120 seconds
for DNS propagation
certbot certonly \\
--dns-google \\
--dns-google-credentials ~/.secrets/certbot/google.json \\
--dns-google-propagation-seconds 120 \\
-d example.com

View File

@@ -1,21 +1,22 @@
"""DNS Authenticator for Google Cloud DNS."""
import json
import logging
from typing import Any
from typing import Callable
from typing import Dict
from typing import Optional
import google.auth
from google.auth import exceptions as googleauth_exceptions
from googleapiclient import discovery
from googleapiclient import errors as googleapiclient_errors
import httplib2
from oauth2client.service_account import ServiceAccountCredentials
from certbot import errors
from certbot.plugins import dns_common
logger = logging.getLogger(__name__)
ADC_URL = 'https://cloud.google.com/docs/authentication/application-default-credentials'
ACCT_URL = 'https://developers.google.com/identity/protocols/OAuth2ServiceAccount#creatinganaccount'
PERMISSIONS_URL = 'https://cloud.google.com/dns/access-control#permissions_and_roles'
METADATA_URL = 'http://metadata.google.internal/computeMetadata/v1/'
@@ -31,15 +32,23 @@ class Authenticator(dns_common.DNSAuthenticator):
description = ('Obtain certificates using a DNS TXT record (if you are using Google Cloud DNS '
'for DNS).')
ttl = 60
google_client = None
@classmethod
def add_parser_arguments(cls, add: Callable[..., None],
default_propagation_seconds: int = 60) -> None:
super().add_parser_arguments(add, default_propagation_seconds=60)
add('credentials',
help=('Path to Google Cloud DNS service account JSON file. (See {0} for' +
'information about creating a service account and {1} for information about the' +
'required permissions.)').format(ACCT_URL, PERMISSIONS_URL),
help=('Path to Google Cloud DNS service account JSON file to use instead of relying on'
' Application Default Credentials (ADC). (See {0} for information about ADC, {1}'
' for information about creating a service account, and {2} for information about'
' the permissions required to modify Cloud DNS records.)')
.format(ADC_URL, ACCT_URL, PERMISSIONS_URL),
default=None)
add('project',
help=('The ID of the Google Cloud project that the Google Cloud DNS managed zone(s)' +
' reside in. This will be determined automatically if not specified.'),
default=None)
def more_info(self) -> str:
@@ -47,22 +56,19 @@ class Authenticator(dns_common.DNSAuthenticator):
'the Google Cloud DNS API.'
def _setup_credentials(self) -> None:
if self.conf('credentials') is None:
try:
# use project_id query to check for availability of google metadata server
# we won't use the result but know we're not on GCP when an exception is thrown
_GoogleClient.get_project_id()
except (ValueError, httplib2.ServerNotFoundError):
raise errors.PluginError('Unable to get Google Cloud Metadata and no credentials'
' specified. Automatic credential lookup is only '
'available on Google Cloud Platform. Please configure'
' credentials using --dns-google-credentials <file>')
else:
if self.conf('credentials') is not None:
self._configure_file('credentials',
'path to Google Cloud DNS service account JSON file')
dns_common.validate_file_permissions(self.conf('credentials'))
try:
self._get_google_client()
except googleauth_exceptions.DefaultCredentialsError as e:
raise errors.PluginError('Authentication using Google Application Default Credentials '
'failed ({}). Please configure credentials using'
' --dns-google-credentials <file>'.format(e))
def _perform(self, domain: str, validation_name: str, validation: str) -> None:
self._get_google_client().add_txt_record(domain, validation_name, validation, self.ttl)
@@ -70,7 +76,10 @@ class Authenticator(dns_common.DNSAuthenticator):
self._get_google_client().del_txt_record(domain, validation_name, validation, self.ttl)
def _get_google_client(self) -> '_GoogleClient':
return _GoogleClient(self.conf('credentials'))
if self.google_client is None:
self.google_client = _GoogleClient(self.conf('credentials'), self.conf('project'))
return self.google_client
class _GoogleClient:
@@ -79,20 +88,31 @@ class _GoogleClient:
"""
def __init__(self, account_json: Optional[str] = None,
dns_project_id: Optional[str] = None,
dns_api: Optional[discovery.Resource] = None) -> None:
scopes = ['https://www.googleapis.com/auth/ndev.clouddns.readwrite']
credentials = None
project_id = None
if account_json is not None:
try:
credentials = ServiceAccountCredentials.from_json_keyfile_name(account_json, scopes)
with open(account_json) as account:
self.project_id = json.load(account)['project_id']
except Exception as e:
credentials, project_id = google.auth.load_credentials_from_file(
account_json, scopes=scopes)
except googleauth_exceptions.GoogleAuthError as e:
raise errors.PluginError(
"Error parsing credentials file '{}': {}".format(account_json, e))
"Error loading credentials file '{}': {}".format(account_json, e))
else:
credentials = None
self.project_id = self.get_project_id()
credentials, project_id = google.auth.default(scopes=scopes)
if dns_project_id is not None:
project_id = dns_project_id
if not project_id:
raise errors.PluginError('The Google Cloud project could not be automatically '
'determined. Please configure it using --dns-google-project'
' <project>.')
self.project_id = project_id
if not dns_api:
self.dns = discovery.build('dns', 'v1',
@@ -292,26 +312,3 @@ class _GoogleClient:
raise errors.PluginError('Unable to determine managed zone for {0} using zone names: {1}.'
.format(domain, zone_dns_name_guesses))
@staticmethod
def get_project_id() -> str:
"""
Query the google metadata service for the current project ID
This only works on Google Cloud Platform
:raises ServerNotFoundError: Not running on Google Compute or DNS not available
:raises ValueError: Server is found, but response code is not 200
:returns: project id
"""
url = '{0}project/project-id'.format(METADATA_URL)
# Request an access token from the metadata server.
http = httplib2.Http()
r, content = http.request(url, headers=METADATA_HEADERS)
if r.status != 200:
raise ValueError("Invalid status code: {0}".format(r))
if isinstance(content, bytes):
return content.decode()
return content

View File

@@ -4,10 +4,10 @@ import sys
import unittest
from unittest import mock
from google.auth import exceptions as googleauth_exceptions
from googleapiclient import discovery
from googleapiclient.errors import Error
from googleapiclient.http import HttpMock
from httplib2 import ServerNotFoundError
import pytest
from certbot import errors
@@ -20,7 +20,7 @@ from certbot.tests import util as test_util
ACCOUNT_JSON_PATH = '/not/a/real/path.json'
API_ERROR = Error()
PROJECT_ID = "test-test-1"
SCOPES = ['https://www.googleapis.com/auth/ndev.clouddns.readwrite']
class AuthenticatorTest(test_util.TempDirTestCase, dns_test_common.BaseAuthenticatorTest):
@@ -34,22 +34,25 @@ class AuthenticatorTest(test_util.TempDirTestCase, dns_test_common.BaseAuthentic
super().setUp()
self.config = mock.MagicMock(google_credentials=path,
google_project=PROJECT_ID,
google_propagation_seconds=0) # don't wait during tests
self.auth = Authenticator(self.config, "google")
self.mock_client = mock.MagicMock()
# _get_google_client | pylint: disable=protected-access
self.auth._get_google_client = mock.MagicMock(return_value=self.mock_client)
@test_util.patch_display_util()
def test_perform(self, unused_mock_get_utility):
# _get_google_client | pylint: disable=protected-access
self.auth._get_google_client = mock.MagicMock(return_value=self.mock_client)
self.auth.perform([self.achall])
expected = [mock.call.add_txt_record(DOMAIN, '_acme-challenge.'+DOMAIN, mock.ANY, mock.ANY)]
assert expected == self.mock_client.mock_calls
def test_cleanup(self):
# _get_google_client | pylint: disable=protected-access
self.auth._get_google_client = mock.MagicMock(return_value=self.mock_client)
# _attempt_cleanup | pylint: disable=protected-access
self.auth._attempt_cleanup = True
self.auth.cleanup([self.achall])
@@ -57,13 +60,27 @@ class AuthenticatorTest(test_util.TempDirTestCase, dns_test_common.BaseAuthentic
expected = [mock.call.del_txt_record(DOMAIN, '_acme-challenge.'+DOMAIN, mock.ANY, mock.ANY)]
assert expected == self.mock_client.mock_calls
@mock.patch('httplib2.Http.request', side_effect=ServerNotFoundError)
@test_util.patch_display_util()
def test_without_auth(self, unused_mock_get_utility, unused_mock):
def test_without_auth(self, unused_mock_get_utility):
self.auth._get_google_client = mock.MagicMock(side_effect=googleauth_exceptions.DefaultCredentialsError)
self.config.google_credentials = None
with pytest.raises(PluginError):
self.auth.perform([self.achall])
@mock.patch('certbot_dns_google._internal.dns_google._GoogleClient')
def test_get_google_client(self, client_mock):
test_client = mock.MagicMock()
client_mock.return_value = test_client
self.auth._get_google_client()
assert client_mock.called
assert self.auth.google_client is test_client
def test_get_google_client_cached(self):
test_client = mock.MagicMock()
self.auth.google_client = test_client
assert self.auth._get_google_client() is test_client
class GoogleClientTest(unittest.TestCase):
record_name = "foo"
@@ -81,7 +98,7 @@ class GoogleClientTest(unittest.TestCase):
http_mock = HttpMock(discovery_file, {'status': '200'})
dns_api = discovery.build('dns', 'v1', http=http_mock)
client = _GoogleClient(ACCOUNT_JSON_PATH, dns_api)
client = _GoogleClient(ACCOUNT_JSON_PATH, None, dns_api)
# Setup
mock_mz = mock.MagicMock()
@@ -107,32 +124,67 @@ class GoogleClientTest(unittest.TestCase):
return client, mock_changes
@mock.patch('googleapiclient.discovery.build')
@mock.patch('oauth2client.service_account.ServiceAccountCredentials.from_json_keyfile_name')
@mock.patch('certbot_dns_google._internal.dns_google._GoogleClient.get_project_id')
def test_client_without_credentials(self, get_project_id_mock, credential_mock,
unused_discovery_mock):
@mock.patch('google.auth.default')
def test_client_with_default_credentials(self, credential_mock, discovery_mock):
test_credentials = mock.MagicMock()
credential_mock.return_value = (test_credentials, PROJECT_ID)
from certbot_dns_google._internal.dns_google import _GoogleClient
_GoogleClient(None)
assert not credential_mock.called
assert get_project_id_mock.called
client = _GoogleClient(None)
credential_mock.assert_called_once_with(scopes=SCOPES)
assert client.project_id == PROJECT_ID
discovery_mock.assert_called_once_with('dns', 'v1',
credentials=test_credentials,
cache_discovery=False)
@mock.patch('oauth2client.service_account.ServiceAccountCredentials.from_json_keyfile_name')
@mock.patch('googleapiclient.discovery.build')
@mock.patch('google.auth.load_credentials_from_file')
def test_client_with_json_credentials(self, credential_mock, discovery_mock):
test_credentials = mock.MagicMock()
credential_mock.return_value = (test_credentials, PROJECT_ID)
from certbot_dns_google._internal.dns_google import _GoogleClient
client = _GoogleClient(ACCOUNT_JSON_PATH)
credential_mock.assert_called_once_with(ACCOUNT_JSON_PATH, scopes=SCOPES)
assert credential_mock.called
assert client.project_id == PROJECT_ID
discovery_mock.assert_called_once_with('dns', 'v1',
credentials=test_credentials,
cache_discovery=False)
@mock.patch('google.auth.load_credentials_from_file')
def test_client_bad_credentials_file(self, credential_mock):
credential_mock.side_effect = ValueError('Some exception buried in oauth2client')
credential_mock.side_effect = googleauth_exceptions.DefaultCredentialsError('Some exception buried in google.auth')
with pytest.raises(errors.PluginError) as exc_info:
self._setUp_client_with_mock([])
assert str(exc_info.value) == \
"Error parsing credentials file '/not/a/real/path.json': " \
"Some exception buried in oauth2client"
"Error loading credentials file '/not/a/real/path.json': " \
"Some exception buried in google.auth"
@mock.patch('oauth2client.service_account.ServiceAccountCredentials.from_json_keyfile_name')
@mock.patch('google.auth.load_credentials_from_file')
def test_client_missing_project_id(self, credential_mock):
credential_mock.return_value = (mock.MagicMock(), "")
with pytest.raises(errors.PluginError) as exc_info:
self._setUp_client_with_mock([])
assert str(exc_info.value) == \
"The Google Cloud project could not be automatically determined. " \
"Please configure it using --dns-google-project <project>."
@mock.patch('googleapiclient.discovery.build')
@mock.patch('google.auth.default')
def test_client_with_project_id(self, credential_mock, unused_discovery_mock):
credential_mock.return_value = (mock.MagicMock(), PROJECT_ID)
from certbot_dns_google._internal.dns_google import _GoogleClient
client = _GoogleClient(None, "test-project-2")
assert credential_mock.called
assert client.project_id == "test-project-2"
@mock.patch('google.auth.load_credentials_from_file')
@mock.patch('certbot_dns_google._internal.dns_google.open',
mock.mock_open(read_data='{"project_id": "' + PROJECT_ID + '"}'), create=True)
@mock.patch('certbot_dns_google._internal.dns_google._GoogleClient.get_project_id')
def test_add_txt_record(self, get_project_id_mock, credential_mock):
def test_add_txt_record(self, credential_mock):
credential_mock.return_value = (mock.MagicMock(), PROJECT_ID)
client, changes = self._setUp_client_with_mock([{'managedZones': [{'id': self.zone}]}])
credential_mock.assert_called_once_with('/not/a/real/path.json', mock.ANY)
assert not get_project_id_mock.called
credential_mock.assert_called_once_with('/not/a/real/path.json', scopes=SCOPES)
client.add_txt_record(DOMAIN, self.record_name, self.record_content, self.record_ttl)
@@ -153,10 +205,12 @@ class GoogleClientTest(unittest.TestCase):
managedZone=self.zone,
project=PROJECT_ID)
@mock.patch('oauth2client.service_account.ServiceAccountCredentials.from_json_keyfile_name')
@mock.patch('google.auth.load_credentials_from_file')
@mock.patch('certbot_dns_google._internal.dns_google.open',
mock.mock_open(read_data='{"project_id": "' + PROJECT_ID + '"}'), create=True)
def test_add_txt_record_and_poll(self, unused_credential_mock):
def test_add_txt_record_and_poll(self, credential_mock):
credential_mock.return_value = (mock.MagicMock(), PROJECT_ID)
client, changes = self._setUp_client_with_mock([{'managedZones': [{'id': self.zone}]}])
changes.create.return_value.execute.return_value = {'status': 'pending', 'id': self.change}
changes.get.return_value.execute.return_value = {'status': 'done'}
@@ -171,10 +225,12 @@ class GoogleClientTest(unittest.TestCase):
managedZone=self.zone,
project=PROJECT_ID)
@mock.patch('oauth2client.service_account.ServiceAccountCredentials.from_json_keyfile_name')
@mock.patch('google.auth.load_credentials_from_file')
@mock.patch('certbot_dns_google._internal.dns_google.open',
mock.mock_open(read_data='{"project_id": "' + PROJECT_ID + '"}'), create=True)
def test_add_txt_record_delete_old(self, unused_credential_mock):
def test_add_txt_record_delete_old(self, credential_mock):
credential_mock.return_value = (mock.MagicMock(), PROJECT_ID)
client, changes = self._setUp_client_with_mock(
[{'managedZones': [{'id': self.zone}]}])
# pylint: disable=line-too-long
@@ -187,10 +243,12 @@ class GoogleClientTest(unittest.TestCase):
assert "sample-txt-contents" in deletions["rrdatas"]
assert self.record_ttl == deletions["ttl"]
@mock.patch('oauth2client.service_account.ServiceAccountCredentials.from_json_keyfile_name')
@mock.patch('google.auth.load_credentials_from_file')
@mock.patch('certbot_dns_google._internal.dns_google.open',
mock.mock_open(read_data='{"project_id": "' + PROJECT_ID + '"}'), create=True)
def test_add_txt_record_delete_old_ttl_case(self, unused_credential_mock):
def test_add_txt_record_delete_old_ttl_case(self, credential_mock):
credential_mock.return_value = (mock.MagicMock(), PROJECT_ID)
client, changes = self._setUp_client_with_mock(
[{'managedZones': [{'id': self.zone}]}])
# pylint: disable=line-too-long
@@ -204,49 +262,59 @@ class GoogleClientTest(unittest.TestCase):
assert "sample-txt-contents" in deletions["rrdatas"]
assert custom_ttl == deletions["ttl"] #otherwise HTTP 412
@mock.patch('oauth2client.service_account.ServiceAccountCredentials.from_json_keyfile_name')
@mock.patch('google.auth.load_credentials_from_file')
@mock.patch('certbot_dns_google._internal.dns_google.open',
mock.mock_open(read_data='{"project_id": "' + PROJECT_ID + '"}'), create=True)
def test_add_txt_record_noop(self, unused_credential_mock):
def test_add_txt_record_noop(self, credential_mock):
credential_mock.return_value = (mock.MagicMock(), PROJECT_ID)
client, changes = self._setUp_client_with_mock(
[{'managedZones': [{'id': self.zone}]}])
client.add_txt_record(DOMAIN, "_acme-challenge.example.org",
"example-txt-contents", self.record_ttl)
assert changes.create.called is False
@mock.patch('oauth2client.service_account.ServiceAccountCredentials.from_json_keyfile_name')
@mock.patch('google.auth.load_credentials_from_file')
@mock.patch('certbot_dns_google._internal.dns_google.open',
mock.mock_open(read_data='{"project_id": "' + PROJECT_ID + '"}'), create=True)
def test_add_txt_record_error_during_zone_lookup(self, unused_credential_mock):
def test_add_txt_record_error_during_zone_lookup(self, credential_mock):
credential_mock.return_value = (mock.MagicMock(), PROJECT_ID)
client, unused_changes = self._setUp_client_with_mock(API_ERROR)
with pytest.raises(errors.PluginError):
client.add_txt_record(DOMAIN, self.record_name, self.record_content, self.record_ttl)
@mock.patch('oauth2client.service_account.ServiceAccountCredentials.from_json_keyfile_name')
@mock.patch('google.auth.load_credentials_from_file')
@mock.patch('certbot_dns_google._internal.dns_google.open',
mock.mock_open(read_data='{"project_id": "' + PROJECT_ID + '"}'), create=True)
def test_add_txt_record_zone_not_found(self, unused_credential_mock):
def test_add_txt_record_zone_not_found(self, credential_mock):
credential_mock.return_value = (mock.MagicMock(), PROJECT_ID)
client, unused_changes = self._setUp_client_with_mock([{'managedZones': []},
{'managedZones': []}])
with pytest.raises(errors.PluginError):
client.add_txt_record(DOMAIN, self.record_name, self.record_content, self.record_ttl)
@mock.patch('oauth2client.service_account.ServiceAccountCredentials.from_json_keyfile_name')
@mock.patch('google.auth.load_credentials_from_file')
@mock.patch('certbot_dns_google._internal.dns_google.open',
mock.mock_open(read_data='{"project_id": "' + PROJECT_ID + '"}'), create=True)
def test_add_txt_record_error_during_add(self, unused_credential_mock):
def test_add_txt_record_error_during_add(self, credential_mock):
credential_mock.return_value = (mock.MagicMock(), PROJECT_ID)
client, changes = self._setUp_client_with_mock([{'managedZones': [{'id': self.zone}]}])
changes.create.side_effect = API_ERROR
with pytest.raises(errors.PluginError):
client.add_txt_record(DOMAIN, self.record_name, self.record_content, self.record_ttl)
@mock.patch('oauth2client.service_account.ServiceAccountCredentials.from_json_keyfile_name')
@mock.patch('google.auth.load_credentials_from_file')
@mock.patch('certbot_dns_google._internal.dns_google.open',
mock.mock_open(read_data='{"project_id": "' + PROJECT_ID + '"}'), create=True)
def test_del_txt_record_multi_rrdatas(self, unused_credential_mock):
def test_del_txt_record_multi_rrdatas(self, credential_mock):
credential_mock.return_value = (mock.MagicMock(), PROJECT_ID)
client, changes = self._setUp_client_with_mock([{'managedZones': [{'id': self.zone}]}])
# pylint: disable=line-too-long
mock_get_rrs = "certbot_dns_google._internal.dns_google._GoogleClient.get_existing_txt_rrset"
@@ -282,10 +350,12 @@ class GoogleClientTest(unittest.TestCase):
managedZone=self.zone,
project=PROJECT_ID)
@mock.patch('oauth2client.service_account.ServiceAccountCredentials.from_json_keyfile_name')
@mock.patch('google.auth.load_credentials_from_file')
@mock.patch('certbot_dns_google._internal.dns_google.open',
mock.mock_open(read_data='{"project_id": "' + PROJECT_ID + '"}'), create=True)
def test_del_txt_record_single_rrdatas(self, unused_credential_mock):
def test_del_txt_record_single_rrdatas(self, credential_mock):
credential_mock.return_value = (mock.MagicMock(), PROJECT_ID)
client, changes = self._setUp_client_with_mock([{'managedZones': [{'id': self.zone}]}])
# pylint: disable=line-too-long
mock_get_rrs = "certbot_dns_google._internal.dns_google._GoogleClient.get_existing_txt_rrset"
@@ -311,36 +381,44 @@ class GoogleClientTest(unittest.TestCase):
managedZone=self.zone,
project=PROJECT_ID)
@mock.patch('oauth2client.service_account.ServiceAccountCredentials.from_json_keyfile_name')
@mock.patch('google.auth.load_credentials_from_file')
@mock.patch('certbot_dns_google._internal.dns_google.open',
mock.mock_open(read_data='{"project_id": "' + PROJECT_ID + '"}'), create=True)
def test_del_txt_record_error_during_zone_lookup(self, unused_credential_mock):
def test_del_txt_record_error_during_zone_lookup(self, credential_mock):
credential_mock.return_value = (mock.MagicMock(), PROJECT_ID)
client, changes = self._setUp_client_with_mock(API_ERROR)
client.del_txt_record(DOMAIN, self.record_name, self.record_content, self.record_ttl)
changes.create.assert_not_called()
@mock.patch('oauth2client.service_account.ServiceAccountCredentials.from_json_keyfile_name')
@mock.patch('google.auth.load_credentials_from_file')
@mock.patch('certbot_dns_google._internal.dns_google.open',
mock.mock_open(read_data='{"project_id": "' + PROJECT_ID + '"}'), create=True)
def test_del_txt_record_zone_not_found(self, unused_credential_mock):
def test_del_txt_record_zone_not_found(self, credential_mock):
credential_mock.return_value = (mock.MagicMock(), PROJECT_ID)
client, changes = self._setUp_client_with_mock([{'managedZones': []},
{'managedZones': []}])
client.del_txt_record(DOMAIN, self.record_name, self.record_content, self.record_ttl)
changes.create.assert_not_called()
@mock.patch('oauth2client.service_account.ServiceAccountCredentials.from_json_keyfile_name')
@mock.patch('google.auth.load_credentials_from_file')
@mock.patch('certbot_dns_google._internal.dns_google.open',
mock.mock_open(read_data='{"project_id": "' + PROJECT_ID + '"}'), create=True)
def test_del_txt_record_error_during_delete(self, unused_credential_mock):
def test_del_txt_record_error_during_delete(self, credential_mock):
credential_mock.return_value = (mock.MagicMock(), PROJECT_ID)
client, changes = self._setUp_client_with_mock([{'managedZones': [{'id': self.zone}]}])
changes.create.side_effect = API_ERROR
client.del_txt_record(DOMAIN, self.record_name, self.record_content, self.record_ttl)
@mock.patch('oauth2client.service_account.ServiceAccountCredentials.from_json_keyfile_name')
@mock.patch('google.auth.load_credentials_from_file')
@mock.patch('certbot_dns_google._internal.dns_google.open',
mock.mock_open(read_data='{"project_id": "' + PROJECT_ID + '"}'), create=True)
def test_get_existing_found(self, unused_credential_mock):
def test_get_existing_found(self, credential_mock):
credential_mock.return_value = (mock.MagicMock(), PROJECT_ID)
client, unused_changes = self._setUp_client_with_mock(
[{'managedZones': [{'id': self.zone}]}])
# Record name mocked in setUp
@@ -348,69 +426,39 @@ class GoogleClientTest(unittest.TestCase):
assert found["rrdatas"] == ["\"example-txt-contents\""]
assert found["ttl"] == 60
@mock.patch('oauth2client.service_account.ServiceAccountCredentials.from_json_keyfile_name')
@mock.patch('google.auth.load_credentials_from_file')
@mock.patch('certbot_dns_google._internal.dns_google.open',
mock.mock_open(read_data='{"project_id": "' + PROJECT_ID + '"}'), create=True)
def test_get_existing_not_found(self, unused_credential_mock):
def test_get_existing_not_found(self, credential_mock):
credential_mock.return_value = (mock.MagicMock(), PROJECT_ID)
client, unused_changes = self._setUp_client_with_mock(
[{'managedZones': [{'id': self.zone}]}])
not_found = client.get_existing_txt_rrset(self.zone, "nonexistent.tld")
assert not_found is None
@mock.patch('oauth2client.service_account.ServiceAccountCredentials.from_json_keyfile_name')
@mock.patch('google.auth.load_credentials_from_file')
@mock.patch('certbot_dns_google._internal.dns_google.open',
mock.mock_open(read_data='{"project_id": "' + PROJECT_ID + '"}'), create=True)
def test_get_existing_with_error(self, unused_credential_mock):
def test_get_existing_with_error(self, credential_mock):
credential_mock.return_value = (mock.MagicMock(), PROJECT_ID)
client, unused_changes = self._setUp_client_with_mock(
[{'managedZones': [{'id': self.zone}]}], API_ERROR)
# Record name mocked in setUp
found = client.get_existing_txt_rrset(self.zone, "_acme-challenge.example.org")
assert found is None
@mock.patch('oauth2client.service_account.ServiceAccountCredentials.from_json_keyfile_name')
@mock.patch('google.auth.load_credentials_from_file')
@mock.patch('certbot_dns_google._internal.dns_google.open',
mock.mock_open(read_data='{"project_id": "' + PROJECT_ID + '"}'), create=True)
def test_get_existing_fallback(self, unused_credential_mock):
def test_get_existing_fallback(self, credential_mock):
credential_mock.return_value = (mock.MagicMock(), PROJECT_ID)
client, unused_changes = self._setUp_client_with_mock(
[{'managedZones': [{'id': self.zone}]}], API_ERROR)
rrset = client.get_existing_txt_rrset(self.zone, "_acme-challenge.example.org")
assert not rrset
def test_get_project_id(self):
from certbot_dns_google._internal.dns_google import _GoogleClient
response = DummyResponse()
response.status = 200
with mock.patch('httplib2.Http.request', return_value=(response, 'test-test-1')):
project_id = _GoogleClient.get_project_id()
assert project_id == 'test-test-1'
with mock.patch('httplib2.Http.request', return_value=(response, b'test-test-1')):
project_id = _GoogleClient.get_project_id()
assert project_id == 'test-test-1'
failed_response = DummyResponse()
failed_response.status = 404
with mock.patch('httplib2.Http.request',
return_value=(failed_response, "some detailed http error response")):
with pytest.raises(ValueError):
_GoogleClient.get_project_id()
with mock.patch('httplib2.Http.request', side_effect=ServerNotFoundError):
with pytest.raises(ServerNotFoundError):
_GoogleClient.get_project_id()
class DummyResponse:
"""
Dummy object to create a fake HTTPResponse (the actual one requires a socket and we only
need the status attribute)
"""
def __init__(self):
self.status = 200
if __name__ == "__main__":
sys.exit(pytest.main(sys.argv[1:] + [__file__])) # pragma: no cover

View File

@@ -7,11 +7,9 @@ from setuptools import setup
version = '2.6.0.dev0'
install_requires = [
'google-api-python-client>=1.5.5',
'oauth2client>=4.0',
'google-api-python-client>=1.6.5',
'google-auth>=2.16.0',
'setuptools>=41.6.0',
# already a dependency of google-api-python-client, but added for consistency
'httplib2'
]
if not os.environ.get('SNAP_BUILD'):

View File

@@ -6,16 +6,27 @@ Certbot adheres to [Semantic Versioning](https://semver.org/).
### Added
* `--dns-google-project` optionally allows for specifying the project that the DNS zone(s) reside in,
which allows for Certbot usage in scenarios where the auth credentials reside in a different
project to the zone(s) that are being managed.
*
### Changed
* Lineage name validity is performed for new lineages. `--cert-name` may no longer contain
filepath separators (i.e. `/` or `\`, depending on the platform).
* `certbot-dns-google` now loads credentials using the standard [Application Default
Credentials](https://cloud.google.com/docs/authentication/application-default-credentials) strategy,
rather than explicitly requiring the Google Compute metadata server to be present if a service account
is not provided using `--dns-google-credentials`.
* `--dns-google-credentials` now supports additional types of file-based credential, such as
[External Account Credentials](https://google.aip.dev/auth/4117) created by Workload Identity
Federation. All file-based credentials implemented by the Google Auth library are supported.
*
### Fixed
*
* `certbot-dns-google` no longer requires deprecated `oauth2client` library.
More details about these changes can be found on our GitHub repo.

View File

@@ -3,9 +3,9 @@
apacheconfig==0.3.2 ; python_full_version < "3.8.0" and python_version >= "3.7"
asn1crypto==0.24.0 ; python_full_version >= "3.7.0" and python_full_version < "3.8.0"
astroid==2.15.2 ; python_full_version >= "3.7.2" and python_full_version < "3.8.0"
attrs==22.2.0 ; python_version >= "3.7" and python_full_version < "3.8.0"
boto3==1.15.15 ; python_full_version < "3.8.0" and python_version >= "3.7"
botocore==1.18.15 ; python_full_version < "3.8.0" and python_version >= "3.7"
cachetools==5.3.0 ; python_version >= "3.7" and python_full_version < "3.8.0"
certifi==2022.12.7 ; python_full_version < "3.8.0" and python_version >= "3.7"
cffi==1.11.5 ; python_full_version < "3.8.0" and python_version >= "3.7"
chardet==3.0.4 ; python_full_version < "3.8.0" and python_version >= "3.7"
@@ -26,10 +26,11 @@ execnet==1.9.0 ; python_version >= "3.7" and python_full_version < "3.8.0"
filelock==3.11.0 ; python_version >= "3.7" and python_full_version < "3.8.0"
funcsigs==0.4 ; python_full_version >= "3.7.0" and python_full_version < "3.8.0"
future==0.18.3 ; python_full_version < "3.8.0" and python_version >= "3.7"
google-api-python-client==1.5.5 ; python_full_version < "3.8.0" and python_version >= "3.7"
google-api-python-client==1.6.5 ; python_full_version < "3.8.0" and python_version >= "3.7"
google-auth==2.16.0 ; python_full_version < "3.8.0" and python_version >= "3.7"
httplib2==0.9.2 ; python_full_version < "3.8.0" and python_version >= "3.7"
idna==2.6 ; python_full_version < "3.8.0" and python_version >= "3.7"
importlib-metadata==6.1.0 ; python_version >= "3.7" and python_version < "3.8"
importlib-metadata==6.4.1 ; python_version >= "3.7" and python_version < "3.8"
iniconfig==2.0.0 ; python_version >= "3.7" and python_full_version < "3.8.0"
ipaddress==1.0.16 ; python_full_version >= "3.7.0" and python_full_version < "3.8.0"
isort==5.11.5 ; python_full_version >= "3.7.2" and python_full_version < "3.8.0"
@@ -41,17 +42,17 @@ mccabe==0.7.0 ; python_full_version >= "3.7.2" and python_full_version < "3.8.0"
mypy-extensions==1.0.0 ; python_version >= "3.7" and python_full_version < "3.8.0"
mypy==1.2.0 ; python_version >= "3.7" and python_full_version < "3.8.0"
ndg-httpsclient==0.3.2 ; python_full_version >= "3.7.0" and python_full_version < "3.8.0"
oauth2client==4.0.0 ; python_full_version < "3.8.0" and python_version >= "3.7"
packaging==23.0 ; python_version >= "3.7" and python_full_version < "3.8.0"
oauth2client==4.1.3 ; python_full_version < "3.8.0" and python_version >= "3.7"
packaging==23.1 ; python_version >= "3.7" and python_full_version < "3.8.0"
parsedatetime==2.4 ; python_full_version < "3.8.0" and python_version >= "3.7"
pbr==1.8.0 ; python_full_version >= "3.7.0" and python_full_version < "3.8.0"
pip==23.0.1 ; python_version >= "3.7" and python_full_version < "3.8.0"
pip==23.1 ; python_version >= "3.7" and python_full_version < "3.8.0"
platformdirs==3.2.0 ; python_full_version < "3.8.0" and python_version >= "3.7"
pluggy==1.0.0 ; python_version >= "3.7" and python_full_version < "3.8.0"
ply==3.4 ; python_full_version < "3.8.0" and python_version >= "3.7"
py==1.11.0 ; python_version >= "3.7" and python_full_version < "3.8.0"
pyasn1-modules==0.0.10 ; python_full_version < "3.8.0" and python_version >= "3.7"
pyasn1==0.1.9 ; python_full_version < "3.8.0" and python_version >= "3.7"
pyasn1-modules==0.2.8 ; python_full_version < "3.8.0" and python_version >= "3.7"
pyasn1==0.4.8 ; python_full_version < "3.8.0" and python_version >= "3.7"
pycparser==2.14 ; python_full_version < "3.8.0" and python_version >= "3.7"
pylint==2.17.2 ; python_full_version >= "3.7.2" and python_full_version < "3.8.0"
pyopenssl==17.5.0 ; python_full_version < "3.8.0" and python_version >= "3.7"
@@ -59,7 +60,7 @@ pyparsing==2.2.1 ; python_full_version < "3.8.0" and python_version >= "3.7"
pyrfc3339==1.0 ; python_full_version < "3.8.0" and python_version >= "3.7"
pytest-cov==4.0.0 ; python_version >= "3.7" and python_full_version < "3.8.0"
pytest-xdist==3.2.1 ; python_version >= "3.7" and python_full_version < "3.8.0"
pytest==7.2.2 ; python_version >= "3.7" and python_full_version < "3.8.0"
pytest==7.3.1 ; python_version >= "3.7" and python_full_version < "3.8.0"
python-augeas==0.5.0 ; python_full_version < "3.8.0" and python_version >= "3.7"
python-dateutil==2.8.2 ; python_full_version < "3.8.0" and python_version >= "3.7"
python-digitalocean==1.11 ; python_full_version < "3.8.0" and python_version >= "3.7"
@@ -78,10 +79,12 @@ tomlkit==0.11.7 ; python_full_version >= "3.7.2" and python_full_version < "3.8.
tox==1.9.2 ; python_version >= "3.7" and python_full_version < "3.8.0"
typed-ast==1.5.4 ; python_version < "3.8" and python_version >= "3.7"
types-cryptography==3.3.23.2 ; python_version >= "3.7" and python_full_version < "3.8.0"
types-httplib2==0.22.0.1 ; python_version >= "3.7" and python_full_version < "3.8.0"
types-pyopenssl==23.0.0.0 ; python_version >= "3.7" and python_full_version < "3.8.0"
types-pyrfc3339==1.1.1.4 ; python_version >= "3.7" and python_full_version < "3.8.0"
types-python-dateutil==2.8.19.12 ; python_version >= "3.7" and python_full_version < "3.8.0"
types-pytz==2023.3.0.0 ; python_version >= "3.7" and python_full_version < "3.8.0"
types-pywin32==306.0.0.1 ; python_version >= "3.7" and python_full_version < "3.8.0"
types-requests==2.28.11.17 ; python_version >= "3.7" and python_full_version < "3.8.0"
types-setuptools==67.6.0.7 ; python_version >= "3.7" and python_full_version < "3.8.0"
types-six==1.16.21.8 ; python_version >= "3.7" and python_full_version < "3.8.0"

View File

@@ -57,18 +57,18 @@ distro = "1.0.1"
dns-lexicon = "3.2.1"
dnspython = "1.15.0"
funcsigs = "0.4"
google-api-python-client = "1.5.5"
google-api-python-client = "1.6.5"
google-auth = "2.16.0"
httplib2 = "0.9.2"
idna = "2.6"
ipaddress = "1.0.16"
ndg-httpsclient = "0.3.2"
oauth2client = "4.0.0"
parsedatetime = "2.4"
pbr = "1.8.0"
ply = "3.4"
pyOpenSSL = "17.5.0"
pyRFC3339 = "1.0"
pyasn1 = "0.1.9"
pyasn1 = "0.4.8"
pycparser = "2.14"
pyparsing = "2.2.1"
python-augeas = "0.5.0"

View File

@@ -15,10 +15,10 @@ babel==2.12.1 ; python_version >= "3.7" and python_version < "4.0"
backcall==0.2.0 ; python_version >= "3.7" and python_version < "4.0"
backports-cached-property==1.0.2 ; python_version >= "3.7" and python_version < "3.8"
bcrypt==4.0.1 ; python_version >= "3.7" and python_version < "4.0"
beautifulsoup4==4.12.0 ; 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.26.105 ; python_version >= "3.7" and python_version < "4.0"
botocore==1.29.105 ; python_version >= "3.7" and python_version < "4.0"
boto3==1.26.113 ; python_version >= "3.7" and python_version < "4.0"
botocore==1.29.113 ; python_version >= "3.7" and python_version < "4.0"
cachecontrol==0.12.11 ; python_version >= "3.7" and python_version < "4.0"
cachetools==5.3.0 ; python_version >= "3.7" and python_version < "4.0"
cachy==0.3.0 ; python_version >= "3.7" and python_version < "4.0"
@@ -30,7 +30,7 @@ cloudflare==2.11.1 ; python_version >= "3.7" and python_version < "4.0"
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.5.3 ; 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.2 ; python_version >= "3.7" and python_version < "4.0"
coverage==7.2.3 ; python_version >= "3.7" and python_version < "4.0"
crashtest==0.3.1 ; python_version >= "3.7" and python_version < "4.0"
cryptography==40.0.1 ; python_version >= "3.7" and python_version < "4.0"
cython==0.29.34 ; python_version >= "3.7" and python_version < "4.0"
@@ -45,11 +45,11 @@ dulwich==0.20.50 ; python_version >= "3.7" and python_version < "4.0"
exceptiongroup==1.1.1 ; python_version >= "3.7" and python_version < "3.11"
execnet==1.9.0 ; python_version >= "3.7" and python_version < "4.0"
fabric==3.0.0 ; python_version >= "3.7" and python_version < "4.0"
filelock==3.10.7 ; python_version >= "3.7" and python_version < "4.0"
filelock==3.11.0 ; python_version >= "3.7" and python_version < "4.0"
google-api-core==2.11.0 ; python_version >= "3.7" and python_version < "4.0"
google-api-python-client==2.83.0 ; python_version >= "3.7" and python_version < "4.0"
google-api-python-client==2.85.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.17.1 ; python_version >= "3.7" and python_version < "4.0"
google-auth==2.17.3 ; python_version >= "3.7" and python_version < "4.0"
googleapis-common-protos==1.59.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"
@@ -84,10 +84,9 @@ more-itertools==9.1.0 ; python_version >= "3.7" and python_version < "4.0"
msgpack==1.0.5 ; python_version >= "3.7" and python_version < "4.0"
msrest==0.6.21 ; python_version >= "3.7" and python_version < "4.0"
mypy-extensions==1.0.0 ; python_version >= "3.7" and python_version < "4.0"
mypy==1.1.1 ; python_version >= "3.7" and python_version < "4.0"
oauth2client==4.1.3 ; python_version >= "3.7" and python_version < "4.0"
mypy==1.2.0 ; python_version >= "3.7" and python_version < "4.0"
oauthlib==3.2.2 ; python_version >= "3.7" and python_version < "4.0"
packaging==23.0 ; python_version >= "3.7" and python_version < "4.0"
packaging==23.1 ; python_version >= "3.7" and python_version < "4.0"
paramiko==3.1.0 ; python_version >= "3.7" and python_version < "4.0"
parsedatetime==2.6 ; python_version >= "3.7" and python_version < "4.0"
parso==0.8.3 ; python_version >= "3.7" and python_version < "4.0"
@@ -103,13 +102,13 @@ poetry-core==1.3.2 ; python_version >= "3.7" and python_version < "4.0"
poetry-plugin-export==1.2.0 ; python_version >= "3.7" and python_version < "4.0"
poetry==1.2.2 ; python_version >= "3.7" and python_version < "4.0"
prompt-toolkit==3.0.38 ; python_version >= "3.7" and python_version < "4.0"
protobuf==4.22.1 ; python_version >= "3.7" and python_version < "4.0"
protobuf==4.22.3 ; python_version >= "3.7" and python_version < "4.0"
ptyprocess==0.7.0 ; python_version >= "3.7" and python_version < "4.0"
py==1.11.0 ; python_version >= "3.7" and python_version < "4.0"
pyasn1-modules==0.2.8 ; python_version >= "3.7" and python_version < "4.0"
pyasn1==0.4.8 ; python_version >= "3.7" and python_version < "4.0"
pycparser==2.21 ; python_version >= "3.7" and python_version < "4.0"
pygments==2.14.0 ; python_version >= "3.7" and python_version < "4.0"
pygments==2.15.0 ; python_version >= "3.7" and python_version < "4.0"
pylev==1.4.0 ; python_version >= "3.7" and python_version < "4.0"
pylint==2.15.5 ; python_full_version >= "3.7.2" and python_version < "4.0"
pynacl==1.5.0 ; python_version >= "3.7" and python_version < "4.0"
@@ -120,7 +119,7 @@ pyrfc3339==1.1 ; python_version >= "3.7" and python_version < "4.0"
pyrsistent==0.19.3 ; python_version >= "3.7" and python_version < "4.0"
pytest-cov==4.0.0 ; python_version >= "3.7" and python_version < "4.0"
pytest-xdist==3.2.1 ; python_version >= "3.7" and python_version < "4.0"
pytest==7.2.2 ; python_version >= "3.7" and python_version < "4.0"
pytest==7.3.0 ; python_version >= "3.7" and python_version < "4.0"
python-augeas==1.1.0 ; python_version >= "3.7" and python_version < "4.0"
python-dateutil==2.8.2 ; python_version >= "3.7" and python_version < "4.0"
python-digitalocean==1.17.0 ; python_version >= "3.7" and python_version < "4.0"
@@ -135,7 +134,7 @@ requests-oauthlib==1.3.1 ; python_version >= "3.7" and python_version < "4.0"
requests-toolbelt==0.9.1 ; python_version >= "3.7" and python_version < "4.0"
requests==2.28.2 ; python_version >= "3.7" and python_version < "4"
rfc3986==2.0.0 ; python_version >= "3.7" and python_version < "4.0"
rich==13.3.3 ; python_version >= "3.7" and python_version < "4.0"
rich==13.3.4 ; python_version >= "3.7" and python_version < "4.0"
rsa==4.9 ; python_version >= "3.7" and python_version < "4"
s3transfer==0.6.0 ; python_version >= "3.7" and python_version < "4.0"
secretstorage==3.3.3 ; python_version >= "3.7" and python_version < "4.0" and sys_platform == "linux"
@@ -163,7 +162,7 @@ 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.4 ; python_version < "3.8" and python_version >= "3.7"
types-httplib2==0.22.0.1 ; python_version >= "3.7" and python_version < "4.0"
types-pyopenssl==23.1.0.1 ; python_version >= "3.7" and python_version < "4.0"
types-pyopenssl==23.1.0.2 ; python_version >= "3.7" and python_version < "4.0"
types-pyrfc3339==1.1.1.4 ; python_version >= "3.7" and python_version < "4.0"
types-python-dateutil==2.8.19.12 ; python_version >= "3.7" and python_version < "4.0"
types-pytz==2023.3.0.0 ; python_version >= "3.7" and python_version < "4.0"