| FazBrowse GitHub Viewer | Trending | | Home |
| Tools: [Download Repo ZIP] [Original HTTPS Page] |
1 parent 50a43af commit 25deb44
4 files changed
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -25,7 +25,9 @@ | |||
| 25 | 25 | from typing import Any, Iterator, Optional, Union | |
| 26 | 26 | from urllib.parse import urlencode | |
| 27 | 27 | ||
| 28 | + from google import auth as google_auth | ||
| 28 | 29 | from google import genai | |
| 30 | + from google.auth.transport import requests as google_auth_requests | ||
| 29 | 31 | from google.genai import _api_module | |
| 30 | 32 | from google.genai import _common | |
| 31 | 33 | from google.genai import types as genai_types | |
@@ -883,34 +885,58 @@ def generate_access_token( | |||
| 883 | 885 | Returns: | |
| 884 | 886 | str: The signed JWT. | |
| 885 | 887 | """ | |
| 886 | - # Imported here rather than at module scope so that importing this | ||
| 887 | - # module does not require `google-cloud-iam`, which is only needed by | ||
| 888 | - # callers of this method. See b/541269262. | ||
| 889 | - try: | ||
| 890 | - from google.cloud import iam_credentials_v1 # type: ignore[attr-defined] # pylint: disable=g-import-not-at-top | ||
| 891 | - except ImportError as e: | ||
| 892 | - raise ImportError( | ||
| 893 | - "The 'agent_engines.sandboxes.generate_access_token' method " | ||
| 894 | - "requires additional packages. Please install them using pip " | ||
| 895 | - "install google-cloud-aiplatform[agent_engines]" | ||
| 896 | - ) from e | ||
| 897 | - | ||
| 898 | - client = iam_credentials_v1.IAMCredentialsClient() | ||
| 899 | - name = f"projects/-/serviceAccounts/{service_account_email}" | ||
| 888 | + issued_at = int(time.time()) | ||
| 900 | 889 | payload = { | |
| 901 | - "iat": int(time.time()), | ||
| 902 | - "exp": int(time.time()) + timeout, | ||
| 890 | + "iat": issued_at, | ||
| 891 | + "exp": issued_at + timeout, | ||
| 903 | 892 | "iss": service_account_email, | |
| 904 | 893 | "sub": service_account_email, | |
| 905 | 894 | "nonce": secrets.randbelow(1000000000) + 1, | |
| 906 | 895 | "aud": "https://aiplatform.googleapis.com/", # default audience for sandbox proxy | |
| 907 | 896 | } | |
| 908 | - request = iam_credentials_v1.SignJwtRequest( | ||
| 909 | - name=name, | ||
| 910 | - payload=json.dumps(payload), | ||
| 897 | + credentials, _ = google_auth.default( | ||
| 898 | + scopes=["https://www.googleapis.com/auth/cloud-platform"] | ||
| 899 | + ) | ||
| 900 | + # Resolve the endpoint against the credentials' universe domain so this | ||
| 901 | + # keeps working off googleapis.com, the same way google.auth.iam does. | ||
| 902 | + universe_domain = ( | ||
| 903 | + getattr(credentials, "universe_domain", None) or "googleapis.com" | ||
| 911 | 904 | ) | |
| 912 | - response = client.sign_jwt(request=request) | ||
| 913 | - return response.signed_jwt # type: ignore[no-any-return] | ||
| 905 | + session = google_auth_requests.AuthorizedSession(credentials) # type: ignore[no-untyped-call] | ||
| 906 | + # The generated IAM client this replaced used the mTLS endpoint when | ||
| 907 | + # client certificates are enabled, and so does the genai client that | ||
| 908 | + # serves every other call in this module. configure_mtls_channel() | ||
| 909 | + # self-gates on GOOGLE_API_USE_CLIENT_CERTIFICATE and on discovered | ||
| 910 | + # workload certificates, so it is a no-op when mTLS is not in use. | ||
| 911 | + session.configure_mtls_channel() # type: ignore[no-untyped-call] | ||
| 912 | + # mTLS is only defined on the default universe. | ||
| 913 | + host = f"iamcredentials.{universe_domain}" | ||
| 914 | + if session.is_mtls and universe_domain == "googleapis.com": | ||
| 915 | + host = f"iamcredentials.mtls.{universe_domain}" | ||
| 916 | + url = ( | ||
| 917 | + f"https://{host}/v1/" | ||
| 918 | + f"projects/-/serviceAccounts/{service_account_email}:signJwt" | ||
| 919 | + ) | ||
| 920 | + # The generated IAM client this replaced retried UNAVAILABLE and | ||
| 921 | + # DEADLINE_EXCEEDED with initial=0.1s and multiplier=1.3 under a 60s | ||
| 922 | + # total deadline. requests does not retry at all, so reproduce that | ||
| 923 | + # policy here rather than silently dropping it. | ||
| 924 | + deadline = time.monotonic() + 60.0 | ||
| 925 | + delay = 0.1 | ||
| 926 | + while True: | ||
| 927 | + response = session.post( | ||
| 928 | + url, | ||
| 929 | + json={"payload": json.dumps(payload)}, | ||
| 930 | + timeout=max(1.0, deadline - time.monotonic()), | ||
| 931 | + ) | ||
| 932 | + if response.status_code not in (503, 504): | ||
| 933 | + break | ||
| 934 | + if deadline - time.monotonic() <= delay: | ||
| 935 | + break | ||
| 936 | + time.sleep(delay) | ||
| 937 | + delay *= 1.3 | ||
| 938 | + response.raise_for_status() | ||
| 939 | + return response.json()["signedJwt"] # type: ignore[no-any-return] | ||
| 914 | 940 | ||
| 915 | 941 | def send_command( | |
| 916 | 942 | self, | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -175,7 +175,6 @@ | |||
| 175 | 175 | "opentelemetry-exporter-otlp-proto-http < 2", | |
| 176 | 176 | "pydantic >= 2.11.1, < 3", | |
| 177 | 177 | "typing_extensions", | |
| 178 | - "google-cloud-iam", | ||
| 179 | 178 | "aiohttp", # for ADK users to use aiohttp rather than httpx client | |
| 180 | 179 | ] | |
| 181 | 180 | ||
@@ -275,7 +274,6 @@ | |||
| 275 | 274 | "bigframes; python_version>='3.10' and python_version<'3.14'", | |
| 276 | 275 | # google-api-core 2.x is required since kfp requires protobuf > 4 | |
| 277 | 276 | "google-api-core >= 2.11, < 3.0.0", | |
| 278 | - "google-cloud-iam", | ||
| 279 | 277 | "grpcio-testing", | |
| 280 | 278 | "grpcio-tools >= 1.63.0; python_version>='3.13'", | |
| 281 | 279 | "ipython", | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -14,13 +14,12 @@ | |||
| 14 | 14 | # | |
| 15 | 15 | ||
| 16 | 16 | import importlib | |
| 17 | + import json | ||
| 17 | 18 | import os | |
| 18 | - import sys | ||
| 19 | 19 | from unittest import mock | |
| 20 | 20 | ||
| 21 | 21 | from google import auth | |
| 22 | 22 | from google.auth import credentials as auth_credentials | |
| 23 | - import google.cloud | ||
| 24 | 23 | import agentplatform | |
| 25 | 24 | from google.cloud import aiplatform | |
| 26 | 25 | from agentplatform._genai import sandboxes | |
@@ -138,43 +137,190 @@ def test_generate_browser_ws_headers( | |||
| 138 | 137 | ) | |
| 139 | 138 | ||
| 140 | 139 | ||
| 141 | - @pytest.mark.parametrize( | ||
| 140 | + _MODULES = pytest.mark.parametrize( | ||
| 142 | 141 | "module", | |
| 143 | 142 | [sandboxes, vertexai_sandboxes], | |
| 144 | 143 | ids=["agentplatform", "vertexai"], | |
| 145 | 144 | ) | |
| 146 | - def test_sandboxes_module_does_not_import_google_cloud_iam_at_module_scope(module): | ||
| 147 | - """The module must be importable when `google-cloud-iam` is absent. | ||
| 148 | 145 | ||
| 149 | - Only `generate_access_token` needs the package, so importing the module - | ||
| 150 | - which is what the `client.agent_engines.sandboxes` property does - must not | ||
| 151 | - require it. Regression test for b/507135729; see b/541269262. | ||
| 146 | + | ||
| 147 | + class _NoUniverseDomainCredentials: | ||
| 148 | + """Credentials without a `universe_domain`, to exercise the fallback.""" | ||
| 149 | + | ||
| 150 | + | ||
| 151 | + def _mock_signing(module, credentials, responses, is_mtls=False): | ||
| 152 | + """Patches google_auth.default and AuthorizedSession for `module`.""" | ||
| 153 | + session = mock.Mock(is_mtls=is_mtls) | ||
| 154 | + session.post.side_effect = responses | ||
| 155 | + return ( | ||
| 156 | + mock.patch.object( | ||
| 157 | + module.google_auth, | ||
| 158 | + "default", | ||
| 159 | + return_value=(credentials, _TEST_PROJECT), | ||
| 160 | + ), | ||
| 161 | + mock.patch.object( | ||
| 162 | + module.google_auth_requests, | ||
| 163 | + "AuthorizedSession", | ||
| 164 | + return_value=session, | ||
| 165 | + ), | ||
| 166 | + session, | ||
| 167 | + ) | ||
| 168 | + | ||
| 169 | + | ||
| 170 | + def _ok_response(signed_jwt="signed-jwt-value"): | ||
| 171 | + response = mock.Mock(status_code=200) | ||
| 172 | + response.json.return_value = {"signedJwt": signed_jwt} | ||
| 173 | + return response | ||
| 174 | + | ||
| 175 | + | ||
| 176 | + @_MODULES | ||
| 177 | + def test_sandboxes_module_does_not_reference_google_cloud_iam(module): | ||
| 178 | + """`google-cloud-iam` is no longer a dependency of this SDK. | ||
| 179 | + | ||
| 180 | + Signing goes through `google-auth`, a core requirement, so nothing may | ||
| 181 | + reach for `iam_credentials_v1` again. See b/541269262. | ||
| 152 | 182 | """ | |
| 153 | - # A module-scope `import x` binds `x` as an attribute of the module, so its | ||
| 154 | - # absence is a direct check that the import is not at module scope. | ||
| 155 | 183 | assert not hasattr(module, "iam_credentials_v1") | |
| 184 | + assert not hasattr(module, "iam_credentials") | ||
| 185 | + | ||
| 186 | + | ||
| 187 | + @_MODULES | ||
| 188 | + @pytest.mark.parametrize( | ||
| 189 | + "credentials_factory,expected_host", | ||
| 190 | + [ | ||
| 191 | + (_NoUniverseDomainCredentials, "iamcredentials.googleapis.com"), | ||
| 192 | + ( | ||
| 193 | + lambda: mock.Mock(universe_domain="googleapis.com"), | ||
| 194 | + "iamcredentials.googleapis.com", | ||
| 195 | + ), | ||
| 196 | + ( | ||
| 197 | + lambda: mock.Mock(universe_domain="test.tpc.example"), | ||
| 198 | + "iamcredentials.test.tpc.example", | ||
| 199 | + ), | ||
| 200 | + ], | ||
| 201 | + ids=["no-universe-domain", "default-universe", "tpc-universe"], | ||
| 202 | + ) | ||
| 203 | + def test_generate_access_token_signs_via_google_auth( | ||
| 204 | + module, credentials_factory, expected_host | ||
| 205 | + ): | ||
| 206 | + """The token is minted by POSTing to the IAM Credentials signJwt endpoint.""" | ||
| 207 | + credentials = credentials_factory() | ||
| 208 | + default_patch, session_patch, session = _mock_signing( | ||
| 209 | + module, credentials, [_ok_response()] | ||
| 210 | + ) | ||
| 211 | + | ||
| 212 | + with default_patch as google_auth_default, session_patch as authorized_session: | ||
| 213 | + client_obj = module.Sandboxes(api_client_=mock.Mock()) | ||
| 214 | + token = client_obj.generate_access_token( | ||
| 215 | + service_account_email=_TEST_SERVICE_ACCOUNT_EMAIL, | ||
| 216 | + timeout=1234, | ||
| 217 | + ) | ||
| 218 | + | ||
| 219 | + assert token == "signed-jwt-value" | ||
| 220 | + # Signed with the resolved credentials, at the cloud-platform scope the | ||
| 221 | + # generated IAM client used. | ||
| 222 | + authorized_session.assert_called_once_with(credentials) | ||
| 223 | + assert google_auth_default.call_args.kwargs["scopes"] == [ | ||
| 224 | + "https://www.googleapis.com/auth/cloud-platform" | ||
| 225 | + ] | ||
| 226 | + | ||
| 227 | + assert session.post.call_args.args[0] == ( | ||
| 228 | + f"https://{expected_host}/v1/projects/-/serviceAccounts/" | ||
| 229 | + f"{_TEST_SERVICE_ACCOUNT_EMAIL}:signJwt" | ||
| 230 | + ) | ||
| 231 | + # Always offered; google-auth decides whether mTLS actually applies. | ||
| 232 | + session.configure_mtls_channel.assert_called_once_with() | ||
| 233 | + # A request that never returns must not hang forever. | ||
| 234 | + assert session.post.call_args.kwargs["timeout"] > 0 | ||
| 235 | + | ||
| 236 | + payload = json.loads(session.post.call_args.kwargs["json"]["payload"]) | ||
| 237 | + assert payload["iss"] == _TEST_SERVICE_ACCOUNT_EMAIL | ||
| 238 | + assert payload["sub"] == _TEST_SERVICE_ACCOUNT_EMAIL | ||
| 239 | + assert payload["aud"] == "https://aiplatform.googleapis.com/" | ||
| 240 | + # iat/exp are derived from a single clock read, so this is exact. | ||
| 241 | + assert payload["exp"] - payload["iat"] == 1234 | ||
| 242 | + | ||
| 243 | + | ||
| 244 | + @_MODULES | ||
| 245 | + @pytest.mark.parametrize("status_code", [503, 504]) | ||
| 246 | + def test_generate_access_token_retries_transient_failures(module, status_code): | ||
| 247 | + """503/504 are retried, as the generated IAM client did.""" | ||
| 248 | + transient = mock.Mock(status_code=status_code) | ||
| 249 | + default_patch, session_patch, session = _mock_signing( | ||
| 250 | + module, | ||
| 251 | + mock.Mock(universe_domain="googleapis.com"), | ||
| 252 | + [transient, transient, _ok_response()], | ||
| 253 | + ) | ||
| 254 | + | ||
| 255 | + with default_patch, session_patch, mock.patch.object( | ||
| 256 | + module.time, "sleep" | ||
| 257 | + ) as sleep: | ||
| 258 | + client_obj = module.Sandboxes(api_client_=mock.Mock()) | ||
| 259 | + token = client_obj.generate_access_token( | ||
| 260 | + service_account_email=_TEST_SERVICE_ACCOUNT_EMAIL | ||
| 261 | + ) | ||
| 262 | + | ||
| 263 | + assert token == "signed-jwt-value" | ||
| 264 | + assert session.post.call_count == 3 | ||
| 265 | + # Backoff grows, matching the replaced client's multiplier. | ||
| 266 | + delays = [call.args[0] for call in sleep.call_args_list] | ||
| 267 | + assert delays == sorted(delays) and delays[0] > 0 | ||
| 268 | + transient.raise_for_status.assert_not_called() | ||
| 269 | + | ||
| 270 | + | ||
| 271 | + @_MODULES | ||
| 272 | + def test_generate_access_token_does_not_retry_client_errors(module): | ||
| 273 | + """A 4xx is surfaced immediately rather than retried.""" | ||
| 274 | + failure = mock.Mock(status_code=403) | ||
| 275 | + failure.raise_for_status.side_effect = ValueError("403 Forbidden") | ||
| 276 | + default_patch, session_patch, session = _mock_signing( | ||
| 277 | + module, mock.Mock(universe_domain="googleapis.com"), [failure] | ||
| 278 | + ) | ||
| 279 | + | ||
| 280 | + with default_patch, session_patch: | ||
| 281 | + client_obj = module.Sandboxes(api_client_=mock.Mock()) | ||
| 282 | + with pytest.raises(ValueError, match="403 Forbidden"): | ||
| 283 | + client_obj.generate_access_token( | ||
| 284 | + service_account_email=_TEST_SERVICE_ACCOUNT_EMAIL | ||
| 285 | + ) | ||
| 286 | + | ||
| 287 | + assert session.post.call_count == 1 | ||
| 288 | + | ||
| 289 | + | ||
| 290 | + @_MODULES | ||
| 291 | + @pytest.mark.parametrize( | ||
| 292 | + "universe_domain,expected_host", | ||
| 293 | + [ | ||
| 294 | + ("googleapis.com", "iamcredentials.mtls.googleapis.com"), | ||
| 295 | + # mTLS is not defined off the default universe, so stay on the plain host. | ||
| 296 | + ("test.tpc.example", "iamcredentials.test.tpc.example"), | ||
| 297 | + ], | ||
| 298 | + ids=["default-universe", "tpc-universe"], | ||
| 299 | + ) | ||
| 300 | + def test_generate_access_token_uses_mtls_endpoint_when_enabled( | ||
| 301 | + module, universe_domain, expected_host | ||
| 302 | + ): | ||
| 303 | + """With client certificates in play the mTLS host is used. | ||
| 304 | + | ||
| 305 | + The generated IAM client this replaced switched to | ||
| 306 | + `iamcredentials.mtls.googleapis.com` under | ||
| 307 | + `GOOGLE_API_USE_CLIENT_CERTIFICATE`, and the genai client backing every | ||
| 308 | + other call in this module does the same. | ||
| 309 | + """ | ||
| 310 | + default_patch, session_patch, session = _mock_signing( | ||
| 311 | + module, | ||
| 312 | + mock.Mock(universe_domain=universe_domain), | ||
| 313 | + [_ok_response()], | ||
| 314 | + is_mtls=True, | ||
| 315 | + ) | ||
| 316 | + | ||
| 317 | + with default_patch, session_patch: | ||
| 318 | + client_obj = module.Sandboxes(api_client_=mock.Mock()) | ||
| 319 | + client_obj.generate_access_token( | ||
| 320 | + service_account_email=_TEST_SERVICE_ACCOUNT_EMAIL | ||
| 321 | + ) | ||
| 156 | 322 | ||
| 157 | - # Belt and braces: re-import the module with the package made unavailable. | ||
| 158 | - # `google.cloud` is a namespace package, so `from google.cloud import x` | ||
| 159 | - # resolves via the parent attribute before consulting sys.modules; the | ||
| 160 | - # attribute has to be removed too, or the block silently does nothing. | ||
| 161 | - name = module.__name__ | ||
| 162 | - had_attr = hasattr(google.cloud, "iam_credentials_v1") | ||
| 163 | - saved_attr = getattr(google.cloud, "iam_credentials_v1", None) | ||
| 164 | - if had_attr: | ||
| 165 | - delattr(google.cloud, "iam_credentials_v1") | ||
| 166 | - try: | ||
| 167 | - with mock.patch.dict( | ||
| 168 | - sys.modules, {"google.cloud.iam_credentials_v1": None} | ||
| 169 | - ): | ||
| 170 | - sys.modules.pop(name, None) | ||
| 171 | - reimported = importlib.import_module(name) | ||
| 172 | - assert reimported.Sandboxes is not None | ||
| 173 | - finally: | ||
| 174 | - if had_attr: | ||
| 175 | - setattr(google.cloud, "iam_credentials_v1", saved_attr) | ||
| 176 | - # `mock.patch.dict` has restored the original module object in | ||
| 177 | - # sys.modules; re-point the parent package attribute at it so that no | ||
| 178 | - # later test sees the copy built while the dependency was blocked. | ||
| 179 | - parent_name, _, leaf = name.rpartition(".") | ||
| 180 | - setattr(sys.modules[parent_name], leaf, sys.modules[name]) | ||
| 323 | + assert session.post.call_args.args[0] == ( | ||
| 324 | + f"https://{expected_host}/v1/projects/-/serviceAccounts/" | ||
| 325 | + f"{_TEST_SERVICE_ACCOUNT_EMAIL}:signJwt" | ||
| 326 | + ) | ||
| Back | FazBrowse Home | New Git URL |
0 commit comments