Skip to content

Commit 07c19d9

Browse files
vertex-sdk-botcopybara-github
authored andcommitted
feat: Add psc_endpoint parameter to Sandboxes.send_command and Sandboxes.generate_browser_ws_headers so VPC-SC sandboxes with ingressControlConfig.enablePrivateServiceConnect=true (which populate only connection_info.service_attachment, never load_balancer_hostname / load_balancer_ip) can route data-plane traffic through a customer-provisioned PSC endpoint. Existing non-VPC-SC callers are unaffected; the parameter defaults to None and the load-balancer branches remain the primary path.
PiperOrigin-RevId: 981254234
1 parent 30bf665 commit 07c19d9

3 files changed

Lines changed: 184 additions & 8 deletions

File tree

‎agentplatform/_genai/sandboxes.py‎

Lines changed: 42 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1362,6 +1362,7 @@ def send_command(
13621362
query_params: Optional[dict[str, object]] = None,
13631363
headers: Optional[dict[str, str]] = None,
13641364
request_dict: Optional[dict[str, object]] = None,
1365+
psc_endpoint: Optional[str] = None,
13651366
) -> genai_types.HttpResponse:
13661367
"""Sends a command to the sandbox.
13671368
@@ -1382,6 +1383,16 @@ def send_command(
13821383
Optional. The headers to include in the command.
13831384
request_dict (dict[str, object]):
13841385
Optional. The request body to include in the command.
1386+
psc_endpoint (str):
1387+
Optional. Host (RFC1918 IP or resolvable internal hostname) of the
1388+
customer-side PSC endpoint forwarding rule that targets
1389+
``connection_info.service_attachment``. Needed for VPC-SC sandboxes
1390+
(``ingressControlConfig.enablePrivateServiceConnect=true``) where
1391+
``connection_info.load_balancer_hostname`` and ``load_balancer_ip``
1392+
are both unset because ingress goes through PSC in the customer's
1393+
VPC rather than a Google-managed load balancer. When provided, it
1394+
is used as the data-plane host in place of the load balancer
1395+
address.
13851396
13861397
Returns:
13871398
genai_types.HttpResponse: The response from the sandbox.
@@ -1391,12 +1402,21 @@ def send_command(
13911402
connection_info = sandbox_environment.connection_info
13921403
if not connection_info:
13931404
raise ValueError("Connection info is not available.")
1394-
if connection_info.load_balancer_hostname:
1405+
if psc_endpoint:
1406+
# VPC-SC path: caller has provisioned a PSC endpoint against
1407+
# connection_info.service_attachment and passes its host here.
1408+
endpoint = "https://" + psc_endpoint
1409+
elif connection_info.load_balancer_hostname:
13951410
endpoint = "https://" + connection_info.load_balancer_hostname
13961411
elif connection_info.load_balancer_ip:
13971412
endpoint = "http://" + connection_info.load_balancer_ip
13981413
else:
1399-
raise ValueError("Load balancer hostname or ip is not available.")
1414+
raise ValueError(
1415+
"No data-plane endpoint available. Non-VPC-SC sandboxes populate"
1416+
" connection_info.load_balancer_hostname; VPC-SC sandboxes require"
1417+
" the caller to pass psc_endpoint (the host of the forwarding"
1418+
" rule targeting connection_info.service_attachment)."
1419+
)
14001420

14011421
routing_token = connection_info.routing_token
14021422
if not routing_token:
@@ -1437,6 +1457,7 @@ def generate_browser_ws_headers(
14371457
service_account_email: str,
14381458
port: str = "8080",
14391459
timeout: int = 3600,
1460+
psc_endpoint: Optional[str] = None,
14401461
) -> tuple[str, dict[str, str]]:
14411462
"""Generates the websocket upgrade headers for the browser.
14421463
@@ -1450,6 +1471,15 @@ def generate_browser_ws_headers(
14501471
Defaults to "8080". This should be one of the ports specified during template creation.
14511472
timeout (int):
14521473
Optional. The timeout in seconds for the token. Defaults to 3600.
1474+
psc_endpoint (str):
1475+
Optional. Host (RFC1918 IP or resolvable internal hostname) of the
1476+
customer-side PSC endpoint forwarding rule that targets
1477+
``connection_info.service_attachment``. Needed for VPC-SC
1478+
sandboxes; see ``send_command`` for details. When provided, it is
1479+
used as the websocket host in place of the load balancer address,
1480+
and is propagated to the internal ``send_command`` call that
1481+
fetches the CDP endpoint.
1482+
14531483
Returns:
14541484
tuple[str, dict[str, str]]: A tuple containing the websocket URL and
14551485
the headers for websocket upgrade.
@@ -1458,12 +1488,19 @@ def generate_browser_ws_headers(
14581488
raise ValueError("Connection info is not available.")
14591489

14601490
connection_info = sandbox_environment.connection_info
1461-
if connection_info.load_balancer_hostname:
1491+
if psc_endpoint:
1492+
ws_base_url = "wss://" + psc_endpoint
1493+
elif connection_info.load_balancer_hostname:
14621494
ws_base_url = "wss://" + connection_info.load_balancer_hostname
14631495
elif connection_info.load_balancer_ip:
14641496
ws_base_url = "ws://" + connection_info.load_balancer_ip
14651497
else:
1466-
raise ValueError("Load balancer hostname or ip is not available.")
1498+
raise ValueError(
1499+
"No data-plane endpoint available. Non-VPC-SC sandboxes populate"
1500+
" connection_info.load_balancer_hostname; VPC-SC sandboxes require"
1501+
" the caller to pass psc_endpoint (the host of the forwarding"
1502+
" rule targeting connection_info.service_attachment)."
1503+
)
14671504

14681505
http_access_token = self.generate_access_token(service_account_email, timeout)
14691506
response = self.send_command(
@@ -1472,6 +1509,7 @@ def generate_browser_ws_headers(
14721509
sandbox_environment=sandbox_environment,
14731510
port=port,
14741511
path="/cdp_ws_endpoint",
1512+
psc_endpoint=psc_endpoint,
14751513
)
14761514
if not response:
14771515
raise ValueError("Failed to get the websocket endpoint.")

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

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,106 @@ def test_generate_browser_ws_headers(
164164
== "v1.stream, test_token, test_routing_token, 9222"
165165
)
166166

167+
@mock.patch.object(sandboxes.requests, "request")
168+
def test_send_command_vpcsc_uses_psc_endpoint(self, mock_request):
169+
mock_sandbox = mock.Mock()
170+
mock_sandbox.connection_info.load_balancer_hostname = None
171+
mock_sandbox.connection_info.load_balancer_ip = None
172+
mock_sandbox.connection_info.routing_token = "test_routing_token"
173+
mock_response = mock.Mock()
174+
mock_response.text = "{}"
175+
mock_response.headers = {}
176+
mock_request.return_value = mock_response
177+
178+
self.client.sandboxes.send_command(
179+
http_method="GET",
180+
access_token="test_token",
181+
sandbox_environment=mock_sandbox,
182+
port="9000",
183+
path="test/path",
184+
psc_endpoint="10.0.0.10",
185+
)
186+
187+
args, kwargs = mock_request.call_args
188+
assert args[0] == "GET"
189+
assert args[1] == "https://10.0.0.10/test/path"
190+
assert kwargs["headers"]["Authorization"] == "Bearer test_token"
191+
assert kwargs["headers"]["X-Sandbox-Routing-Token"] == "test_routing_token"
192+
# X-Sandbox-Port must flow through even on the VPC-SC path; the
193+
# reverse proxy behind the customer's PSC endpoint uses it to route
194+
# to the correct sandbox container port.
195+
assert kwargs["headers"]["X-Sandbox-Port"] == "9000"
196+
197+
@mock.patch.object(sandboxes.requests, "request")
198+
def test_send_command_vpcsc_accepts_hostname_as_psc_endpoint(self, mock_request):
199+
"""psc_endpoint accepts a resolvable hostname, not only an IP."""
200+
mock_sandbox = mock.Mock()
201+
mock_sandbox.connection_info.load_balancer_hostname = None
202+
mock_sandbox.connection_info.load_balancer_ip = None
203+
mock_sandbox.connection_info.routing_token = "test_routing_token"
204+
mock_response = mock.Mock()
205+
mock_response.text = "{}"
206+
mock_response.headers = {}
207+
mock_request.return_value = mock_response
208+
209+
self.client.sandboxes.send_command(
210+
http_method="GET",
211+
access_token="test_token",
212+
sandbox_environment=mock_sandbox,
213+
path="test/path",
214+
psc_endpoint="psc-endpoint.internal.example.com",
215+
)
216+
217+
args, _ = mock_request.call_args
218+
assert args[1] == "https://psc-endpoint.internal.example.com/test/path"
219+
220+
def test_send_command_raises_when_no_endpoint_available(self):
221+
mock_sandbox = mock.Mock()
222+
mock_sandbox.connection_info.load_balancer_hostname = None
223+
mock_sandbox.connection_info.load_balancer_ip = None
224+
mock_sandbox.connection_info.routing_token = "test_routing_token"
225+
226+
with pytest.raises(ValueError, match="psc_endpoint"):
227+
self.client.sandboxes.send_command(
228+
http_method="GET",
229+
access_token="test_token",
230+
sandbox_environment=mock_sandbox,
231+
path="test/path",
232+
)
233+
234+
@mock.patch.object(sandboxes.Sandboxes, "generate_access_token")
235+
@mock.patch.object(sandboxes.requests, "request")
236+
def test_generate_browser_ws_headers_vpcsc_uses_psc_endpoint(
237+
self, mock_request, mock_generate_access_token
238+
):
239+
mock_generate_access_token.return_value = "test_token"
240+
241+
mock_sandbox = mock.Mock()
242+
mock_sandbox.connection_info.load_balancer_hostname = None
243+
mock_sandbox.connection_info.load_balancer_ip = None
244+
mock_sandbox.connection_info.routing_token = "test_routing_token"
245+
mock_response = mock.Mock()
246+
mock_response.text = '{"endpoint": "test/endpoint"}'
247+
mock_response.headers = {}
248+
mock_request.return_value = mock_response
249+
250+
ws_url, headers = self.client.sandboxes.generate_browser_ws_headers(
251+
sandbox_environment=mock_sandbox,
252+
service_account_email=_TEST_SERVICE_ACCOUNT_EMAIL,
253+
timeout=3600,
254+
psc_endpoint="10.0.0.10",
255+
)
256+
assert ws_url == "wss://10.0.0.10/test/endpoint"
257+
assert (
258+
headers["Sec-WebSocket-Protocol"]
259+
== "v1.stream, test_token, test_routing_token, 9222"
260+
)
261+
# The delegated send_command call that fetches the CDP endpoint must
262+
# also target the PSC endpoint host, not the (empty) load balancer.
263+
req_args, req_kwargs = mock_request.call_args
264+
assert req_args[1] == "https://10.0.0.10/cdp_ws_endpoint"
265+
assert req_kwargs["headers"]["X-Sandbox-Routing-Token"] == "test_routing_token"
266+
167267
@mock.patch.object(sandboxes.Sandboxes, "_create")
168268
def test_create_with_shell_environment_and_existing_template(self, mock_create):
169269
mock_operation = mock.Mock()

‎vertexai/_genai/sandboxes.py‎

Lines changed: 42 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -937,6 +937,7 @@ def send_command(
937937
query_params: Optional[dict[str, object]] = None,
938938
headers: Optional[dict[str, str]] = None,
939939
request_dict: Optional[dict[str, object]] = None,
940+
psc_endpoint: Optional[str] = None,
940941
) -> genai_types.HttpResponse:
941942
"""Sends a command to the sandbox.
942943
@@ -957,6 +958,16 @@ def send_command(
957958
Optional. The headers to include in the command.
958959
request_dict (dict[str, object]):
959960
Optional. The request body to include in the command.
961+
psc_endpoint (str):
962+
Optional. Host (RFC1918 IP or resolvable internal hostname) of the
963+
customer-side PSC endpoint forwarding rule that targets
964+
``connection_info.service_attachment``. Needed for VPC-SC sandboxes
965+
(``ingressControlConfig.enablePrivateServiceConnect=true``) where
966+
``connection_info.load_balancer_hostname`` and ``load_balancer_ip``
967+
are both unset because ingress goes through PSC in the customer's
968+
VPC rather than a Google-managed load balancer. When provided, it
969+
is used as the data-plane host in place of the load balancer
970+
address.
960971
961972
Returns:
962973
genai_types.HttpResponse: The response from the sandbox.
@@ -966,12 +977,21 @@ def send_command(
966977
connection_info = sandbox_environment.connection_info
967978
if not connection_info:
968979
raise ValueError("Connection info is not available.")
969-
if connection_info.load_balancer_hostname:
980+
if psc_endpoint:
981+
# VPC-SC path: caller has provisioned a PSC endpoint against
982+
# connection_info.service_attachment and passes its host here.
983+
endpoint = "https://" + psc_endpoint
984+
elif connection_info.load_balancer_hostname:
970985
endpoint = "https://" + connection_info.load_balancer_hostname
971986
elif connection_info.load_balancer_ip:
972987
endpoint = "http://" + connection_info.load_balancer_ip
973988
else:
974-
raise ValueError("Load balancer hostname or ip is not available.")
989+
raise ValueError(
990+
"No data-plane endpoint available. Non-VPC-SC sandboxes populate"
991+
" connection_info.load_balancer_hostname; VPC-SC sandboxes require"
992+
" the caller to pass psc_endpoint (the host of the forwarding"
993+
" rule targeting connection_info.service_attachment)."
994+
)
975995

976996
routing_token = connection_info.routing_token
977997
if not routing_token:
@@ -1012,6 +1032,7 @@ def generate_browser_ws_headers(
10121032
service_account_email: str,
10131033
port: str = "8080",
10141034
timeout: int = 3600,
1035+
psc_endpoint: Optional[str] = None,
10151036
) -> tuple[str, dict[str, str]]:
10161037
"""Generates the websocket upgrade headers for the browser.
10171038
@@ -1025,6 +1046,15 @@ def generate_browser_ws_headers(
10251046
Defaults to "8080". This should be one of the ports specified during template creation.
10261047
timeout (int):
10271048
Optional. The timeout in seconds for the token. Defaults to 3600.
1049+
psc_endpoint (str):
1050+
Optional. Host (RFC1918 IP or resolvable internal hostname) of the
1051+
customer-side PSC endpoint forwarding rule that targets
1052+
``connection_info.service_attachment``. Needed for VPC-SC
1053+
sandboxes; see ``send_command`` for details. When provided, it is
1054+
used as the websocket host in place of the load balancer address,
1055+
and is propagated to the internal ``send_command`` call that
1056+
fetches the CDP endpoint.
1057+
10281058
Returns:
10291059
tuple[str, dict[str, str]]: A tuple containing the websocket URL and
10301060
the headers for websocket upgrade.
@@ -1033,12 +1063,19 @@ def generate_browser_ws_headers(
10331063
raise ValueError("Connection info is not available.")
10341064

10351065
connection_info = sandbox_environment.connection_info
1036-
if connection_info.load_balancer_hostname:
1066+
if psc_endpoint:
1067+
ws_base_url = "wss://" + psc_endpoint
1068+
elif connection_info.load_balancer_hostname:
10371069
ws_base_url = "wss://" + connection_info.load_balancer_hostname
10381070
elif connection_info.load_balancer_ip:
10391071
ws_base_url = "ws://" + connection_info.load_balancer_ip
10401072
else:
1041-
raise ValueError("Load balancer hostname or ip is not available.")
1073+
raise ValueError(
1074+
"No data-plane endpoint available. Non-VPC-SC sandboxes populate"
1075+
" connection_info.load_balancer_hostname; VPC-SC sandboxes require"
1076+
" the caller to pass psc_endpoint (the host of the forwarding"
1077+
" rule targeting connection_info.service_attachment)."
1078+
)
10421079

10431080
http_access_token = self.generate_access_token(service_account_email, timeout)
10441081
response = self.send_command(
@@ -1047,6 +1084,7 @@ def generate_browser_ws_headers(
10471084
sandbox_environment=sandbox_environment,
10481085
port=port,
10491086
path="/cdp_ws_endpoint",
1087+
psc_endpoint=psc_endpoint,
10501088
)
10511089
if not response:
10521090
raise ValueError("Failed to get the websocket endpoint.")

0 commit comments

Comments
 (0)