|
| 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