FazBrowse GitHub Viewer | Trending |
URL:
| Home
Tools: [Download Repo ZIP]   [Original HTTPS Page]

fix: Remove the `google-cloud-iam` dependency from the `agent_engines… · googleapis/python-aiplatform@25deb44 · GitHub

Commit 25deb44

Browse files
authored andcommitted
fix: Remove the google-cloud-iam dependency from the agent_engines extra.
`Sandboxes.generate_access_token()` was the only thing in the SDK using `google-cloud-iam`, for a single `projects.serviceAccounts.signJwt` call. That call is now made directly against the IAM Credentials REST endpoint using `google-auth`, which is already a core requirement, so `google-cloud-iam` is no longer installed by `pip install google-cloud-aiplatform[agent_engines]`. The method signature, the JWT payload and the returned token are unchanged, and the endpoint is now resolved against the credentials universe domain so it also works outside `googleapis.com`. Note that a failed signing call now raises `requests.exceptions.HTTPError` rather than a `google.api_core.exceptions` type. PiperOrigin-RevId: 963592694
1 parent 50a43af commit 25deb44

4 files changed

Lines changed: 274 additions & 78 deletions

File tree

‎agentplatform/_genai/sandboxes.py‎

Lines changed: 47 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,9 @@
2525
from typing import Any, Iterator, Optional, Union
2626
from urllib.parse import urlencode
2727

28+
from google import auth as google_auth
2829
from google import genai
30+
from google.auth.transport import requests as google_auth_requests
2931
from google.genai import _api_module
3032
from google.genai import _common
3133
from google.genai import types as genai_types
@@ -883,34 +885,58 @@ def generate_access_token(
883885
Returns:
884886
str: The signed JWT.
885887
"""
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())
900889
payload = {
901-
"iat": int(time.time()),
902-
"exp": int(time.time()) + timeout,
890+
"iat": issued_at,
891+
"exp": issued_at + timeout,
903892
"iss": service_account_email,
904893
"sub": service_account_email,
905894
"nonce": secrets.randbelow(1000000000) + 1,
906895
"aud": "https://aiplatform.googleapis.com/", # default audience for sandbox proxy
907896
}
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"
911904
)
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]
914940

915941
def send_command(
916942
self,

‎setup.py‎

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -175,7 +175,6 @@
175175
"opentelemetry-exporter-otlp-proto-http < 2",
176176
"pydantic >= 2.11.1, < 3",
177177
"typing_extensions",
178-
"google-cloud-iam",
179178
"aiohttp", # for ADK users to use aiohttp rather than httpx client
180179
]
181180

@@ -275,7 +274,6 @@
275274
"bigframes; python_version>='3.10' and python_version<'3.14'",
276275
# google-api-core 2.x is required since kfp requires protobuf > 4
277276
"google-api-core >= 2.11, < 3.0.0",
278-
"google-cloud-iam",
279277
"grpcio-testing",
280278
"grpcio-tools >= 1.63.0; python_version>='3.13'",
281279
"ipython",

‎tests/unit/agentplatform/genai/test_sandbox.py‎

Lines changed: 180 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,12 @@
1414
#
1515

1616
import importlib
17+
import json
1718
import os
18-
import sys
1919
from unittest import mock
2020

2121
from google import auth
2222
from google.auth import credentials as auth_credentials
23-
import google.cloud
2423
import agentplatform
2524
from google.cloud import aiplatform
2625
from agentplatform._genai import sandboxes
@@ -138,43 +137,190 @@ def test_generate_browser_ws_headers(
138137
)
139138

140139

141-
@pytest.mark.parametrize(
140+
_MODULES = pytest.mark.parametrize(
142141
"module",
143142
[sandboxes, vertexai_sandboxes],
144143
ids=["agentplatform", "vertexai"],
145144
)
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.
148145

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.
152182
"""
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.
155183
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+
)
156322

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

0 commit comments

Comments
 (0)

Back | FazBrowse Home | New Git URL