Skip to content

Commit 57e6cb2

Browse files
author
Bennett Kanuka
committed
add httpx transport; add compute_engine async
1 parent 3fcd319 commit 57e6cb2

File tree

4 files changed

+765
-0
lines changed

4 files changed

+765
-0
lines changed
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
# Copyright 2016 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
"""Google Compute Engine credentials.
16+
17+
This module provides authentication for an application running on Google
18+
Compute Engine using the Compute Engine metadata server.
19+
20+
"""
21+
22+
import google.auth._credentials_async as credentials_async
23+
from google.auth import exceptions
24+
from google.auth.compute_engine import _metadata_async as _metadata
25+
from google.auth.compute_engine import credentials as credentials_sync
26+
27+
28+
class Credentials(
29+
credentials_sync.Credentials,
30+
credentials_async.Credentials,
31+
credentials_async.Scoped,
32+
credentials_async.CredentialsWithQuotaProject
33+
):
34+
"""Async Compute Engine Credentials.
35+
36+
These credentials use the Google Compute Engine metadata server to obtain
37+
OAuth 2.0 access tokens associated with the instance's service account,
38+
and are also used for Cloud Run, Flex and App Engine (except for the Python
39+
2.7 runtime, which is supported only on older versions of this library).
40+
41+
For more information about Compute Engine authentication, including how
42+
to configure scopes, see the `Compute Engine authentication
43+
documentation`_.
44+
45+
.. note:: On Compute Engine the metadata server ignores requested scopes.
46+
On Cloud Run, Flex and App Engine the server honours requested scopes.
47+
48+
.. _Compute Engine authentication documentation:
49+
https://cloud.google.com/compute/docs/authentication#using
50+
"""
51+
52+
def __init__(
53+
self,
54+
service_account_email="default",
55+
quota_project_id=None,
56+
scopes=None,
57+
default_scopes=None,
58+
):
59+
"""
60+
Args:
61+
service_account_email (str): The service account email to use, or
62+
'default'. A Compute Engine instance may have multiple service
63+
accounts.
64+
quota_project_id (Optional[str]): The project ID used for quota and
65+
billing.
66+
scopes (Optional[Sequence[str]]): The list of scopes for the credentials.
67+
default_scopes (Optional[Sequence[str]]): Default scopes passed by a
68+
Google client library. Use 'scopes' for user-defined scopes.
69+
"""
70+
super(Credentials, self).__init__()
71+
self._service_account_email = service_account_email
72+
self._quota_project_id = quota_project_id
73+
self._scopes = scopes
74+
self._default_scopes = default_scopes
75+
76+
async def _retrieve_info(self, request):
77+
"""Retrieve information about the service account.
78+
79+
Updates the scopes and retrieves the full service account email.
80+
81+
Args:
82+
request (google.auth.transport.Request): The object used to make
83+
HTTP requests.
84+
"""
85+
info = await _metadata.get_service_account_info(
86+
request, service_account=self._service_account_email
87+
)
88+
89+
self._service_account_email = info["email"]
90+
91+
# Don't override scopes requested by the user.
92+
if self._scopes is None:
93+
self._scopes = info["scopes"]
94+
95+
async def refresh(self, request):
96+
"""Refresh the access token and scopes.
97+
98+
Args:
99+
request (google.auth.transport.Request): The object used to make
100+
HTTP requests.
101+
102+
Raises:
103+
google.auth.exceptions.RefreshError: If the Compute Engine metadata
104+
service can't be reached or if the instance has no
105+
credentials.
106+
"""
107+
scopes = self._scopes if self._scopes is not None else self._default_scopes
108+
try:
109+
await self._retrieve_info(request)
110+
self.token, self.expiry = await _metadata.get_service_account_token(
111+
request, service_account=self._service_account_email, scopes=scopes
112+
)
113+
except exceptions.TransportError as caught_exc:
114+
new_exc = exceptions.RefreshError(caught_exc)
115+
raise new_exc from caught_exc
Lines changed: 266 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,266 @@
1+
# Copyright 2016 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
"""Provides helper methods for talking to the Compute Engine metadata server.
16+
17+
See https://cloud.google.com/compute/docs/metadata for more details.
18+
"""
19+
20+
import datetime
21+
import json
22+
import logging
23+
import os
24+
from http import HTTPStatus
25+
from urllib.parse import urljoin
26+
27+
from google.auth import _helpers
28+
from google.auth import environment_vars
29+
from google.auth import exceptions
30+
from google.auth.transport import _httpx_requests as httpx_requests
31+
32+
_LOGGER = logging.getLogger(__name__)
33+
34+
# Environment variable GCE_METADATA_HOST is originally named
35+
# GCE_METADATA_ROOT. For compatiblity reasons, here it checks
36+
# the new variable first; if not set, the system falls back
37+
# to the old variable.
38+
_GCE_METADATA_HOST = os.getenv(environment_vars.GCE_METADATA_HOST, None)
39+
if not _GCE_METADATA_HOST:
40+
_GCE_METADATA_HOST = os.getenv(
41+
environment_vars.GCE_METADATA_ROOT, "metadata.google.internal"
42+
)
43+
_METADATA_ROOT = "http://{}/computeMetadata/v1/".format(_GCE_METADATA_HOST)
44+
45+
# This is used to ping the metadata server, it avoids the cost of a DNS
46+
# lookup.
47+
_METADATA_IP_ROOT = "http://{}".format(
48+
os.getenv(environment_vars.GCE_METADATA_IP, "169.254.169.254")
49+
)
50+
_METADATA_FLAVOR_HEADER = "metadata-flavor"
51+
_METADATA_FLAVOR_VALUE = "Google"
52+
_METADATA_HEADERS = {_METADATA_FLAVOR_HEADER: _METADATA_FLAVOR_VALUE}
53+
54+
# Timeout in seconds to wait for the GCE metadata server when detecting the
55+
# GCE environment.
56+
try:
57+
_METADATA_DEFAULT_TIMEOUT = int(os.getenv("GCE_METADATA_TIMEOUT", 3))
58+
except ValueError: # pragma: NO COVER
59+
_METADATA_DEFAULT_TIMEOUT = 3
60+
61+
62+
async def ping(request: httpx_requests.Request, timeout=_METADATA_DEFAULT_TIMEOUT, retry_count=3):
63+
"""Checks to see if the metadata server is available.
64+
65+
Args:
66+
request (httpx_requests.Request): A callable used to make
67+
HTTP requests.
68+
timeout (int): How long to wait for the metadata server to respond.
69+
retry_count (int): How many times to attempt connecting to metadata
70+
server using above timeout.
71+
72+
Returns:
73+
bool: True if the metadata server is reachable, False otherwise.
74+
"""
75+
# NOTE: The explicit ``timeout`` is a workaround. The underlying
76+
# issue is that resolving an unknown host on some networks will take
77+
# 20-30 seconds; making this timeout short fixes the issue, but
78+
# could lead to false negatives in the event that we are on GCE, but
79+
# the metadata resolution was particularly slow. The latter case is
80+
# "unlikely".
81+
retries = 0
82+
while retries < retry_count:
83+
try:
84+
response = await request(
85+
url=_METADATA_IP_ROOT,
86+
method="GET",
87+
headers=_METADATA_HEADERS,
88+
timeout=timeout,
89+
)
90+
91+
metadata_flavor = response.headers.get(_METADATA_FLAVOR_HEADER)
92+
return (
93+
response.status == HTTPStatus.OK
94+
and metadata_flavor == _METADATA_FLAVOR_VALUE
95+
)
96+
97+
except exceptions.TransportError as e:
98+
_LOGGER.warning(
99+
"Compute Engine Metadata server unavailable on "
100+
"attempt %s of %s. Reason: %s",
101+
retries + 1,
102+
retry_count,
103+
e,
104+
)
105+
retries += 1
106+
107+
return False
108+
109+
110+
async def get(
111+
request: httpx_requests.Request, path, root=_METADATA_ROOT, params=None, recursive=False, retry_count=5
112+
):
113+
"""Fetch a resource from the metadata server.
114+
115+
Args:
116+
request (httpx_requests.Request): A callable used to make
117+
HTTP requests.
118+
path (str): The resource to retrieve. For example,
119+
``'instance/service-accounts/default'``.
120+
root (str): The full path to the metadata server root.
121+
params (Optional[Mapping[str, str]]): A mapping of query parameter
122+
keys to values.
123+
recursive (bool): Whether to do a recursive query of metadata. See
124+
https://cloud.google.com/compute/docs/metadata#aggcontents for more
125+
details.
126+
retry_count (int): How many times to attempt connecting to metadata
127+
server using above timeout.
128+
129+
Returns:
130+
Union[Mapping, str]: If the metadata server returns JSON, a mapping of
131+
the decoded JSON is return. Otherwise, the response content is
132+
returned as a string.
133+
134+
Raises:
135+
google.auth.exceptions.TransportError: if an error occurred while
136+
retrieving metadata.
137+
"""
138+
base_url = urljoin(root, path)
139+
query_params = {} if params is None else params
140+
141+
if recursive:
142+
query_params["recursive"] = "true"
143+
144+
url = _helpers.update_query(base_url, query_params)
145+
146+
retries = 0
147+
while retries < retry_count:
148+
try:
149+
response = await request(url=url, method="GET", headers=_METADATA_HEADERS)
150+
break
151+
152+
except exceptions.TransportError as e:
153+
_LOGGER.warning(
154+
"Compute Engine Metadata server unavailable on "
155+
"attempt %s of %s. Reason: %s",
156+
retries + 1,
157+
retry_count,
158+
e,
159+
)
160+
retries += 1
161+
else:
162+
raise exceptions.TransportError(
163+
"Failed to retrieve {} from the Google Compute Engine "
164+
"metadata service. Compute Engine Metadata server unavailable".format(url)
165+
)
166+
167+
if response.status == HTTPStatus.OK:
168+
content = _helpers.from_bytes(response.data)
169+
if response.headers["content-type"] == "application/json":
170+
try:
171+
return json.loads(content)
172+
except ValueError as caught_exc:
173+
new_exc = exceptions.TransportError(
174+
"Received invalid JSON from the Google Compute Engine "
175+
"metadata service: {:.20}".format(content)
176+
)
177+
raise new_exc from caught_exc
178+
else:
179+
return content
180+
else:
181+
raise exceptions.TransportError(
182+
"Failed to retrieve {} from the Google Compute Engine "
183+
"metadata service. Status: {} Response:\n{}".format(
184+
url, response.status, response.data
185+
),
186+
response,
187+
)
188+
189+
190+
async def get_project_id(request: httpx_requests.Request):
191+
"""Get the Google Cloud Project ID from the metadata server.
192+
193+
Args:
194+
request (httpx_requests.Request): A callable used to make
195+
HTTP requests.
196+
197+
Returns:
198+
str: The project ID
199+
200+
Raises:
201+
google.auth.exceptions.TransportError: if an error occurred while
202+
retrieving metadata.
203+
"""
204+
return await get(request, "project/project-id")
205+
206+
207+
async def get_service_account_info(request: httpx_requests.Request, service_account="default"):
208+
"""Get information about a service account from the metadata server.
209+
210+
Args:
211+
request (httpx_requests.Request): A callable used to make
212+
HTTP requests.
213+
service_account (str): The string 'default' or a service account email
214+
address. The determines which service account for which to acquire
215+
information.
216+
217+
Returns:
218+
Mapping: The service account's information, for example::
219+
220+
{
221+
'email': '...',
222+
'scopes': ['scope', ...],
223+
'aliases': ['default', '...']
224+
}
225+
226+
Raises:
227+
google.auth.exceptions.TransportError: if an error occurred while
228+
retrieving metadata.
229+
"""
230+
path = "instance/service-accounts/{0}/".format(service_account)
231+
# See https://cloud.google.com/compute/docs/metadata#aggcontents
232+
# for more on the use of 'recursive'.
233+
return await get(request, path, params={"recursive": "true"})
234+
235+
236+
async def get_service_account_token(request: httpx_requests.Request, service_account="default", scopes=None):
237+
"""Get the OAuth 2.0 access token for a service account.
238+
239+
Args:
240+
request (httpx_requests.Request): A callable used to make
241+
HTTP requests.
242+
service_account (str): The string 'default' or a service account email
243+
address. The determines which service account for which to acquire
244+
an access token.
245+
scopes (Optional[Union[str, List[str]]]): Optional string or list of
246+
strings with auth scopes.
247+
Returns:
248+
Tuple[str, datetime]: The access token and its expiration.
249+
250+
Raises:
251+
google.auth.exceptions.TransportError: if an error occurred while
252+
retrieving metadata.
253+
"""
254+
if scopes:
255+
if not isinstance(scopes, str):
256+
scopes = ",".join(scopes)
257+
params = {"scopes": scopes}
258+
else:
259+
params = None
260+
261+
path = "instance/service-accounts/{0}/token".format(service_account)
262+
token_json = await get(request, path, params=params)
263+
token_expiry = datetime.datetime.utcnow() + datetime.timedelta(
264+
seconds=token_json["expires_in"]
265+
)
266+
return token_json["access_token"], token_expiry

0 commit comments

Comments
 (0)