main
py 323 lines 10.3 KB
Raw
1 # Copyright 2026 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 import uuid
16 import json
17 import pytest
18 from unittest.mock import MagicMock
19 from colab_cli.client import (
20 Client,
21 Prod,
22 PostAssignmentResponse,
23 Assignment,
24 Accelerator,
25 Shape,
26 Variant,
27 resolve_assign_shape,
28 )
29
30
31 @pytest.fixture
32 def mock_session():
33 return MagicMock()
34
35
36 @pytest.fixture
37 def client(mock_session):
38 return Client(Prod(), mock_session)
39
40
41 def test_client_assign_new(client, mock_session):
42 # Mock _get_assignment (GET)
43 get_resp = MagicMock()
44 get_resp.ok = True
45 get_resp.text = ")]}'\n" + json.dumps(
46 {"acc": "NONE", "nbh": "some_nbh", "token": "xsrf_token", "variant": "DEFAULT"}
47 )
48
49 # Mock _post_assignment (POST)
50 post_resp = MagicMock()
51 post_resp.ok = True
52 post_resp.text = ")]}'\n" + json.dumps(
53 {
54 "accelerator": "NONE",
55 "endpoint": "new_endpoint",
56 "runtimeProxyInfo": {
57 "token": "proxy_token",
58 "tokenExpiresInSeconds": 3600,
59 "url": "http://backend",
60 },
61 "variant": 0,
62 }
63 )
64
65 mock_session.request.side_effect = [get_resp, post_resp]
66
67 res = client.assign(uuid.uuid4())
68
69 assert isinstance(res, PostAssignmentResponse)
70 assert res.endpoint == "new_endpoint"
71 assert res.runtime_proxy_info.token == "proxy_token"
72
73 # Check POST request headers for XSRF token
74 assert mock_session.request.call_count == 2
75 last_call_args = mock_session.request.call_args_list[1]
76 assert last_call_args.kwargs["headers"]["X-Goog-Colab-Token"] == "xsrf_token"
77
78
79 def test_client_unassign(client, mock_session):
80 # Mock GET for XSRF token
81 get_resp = MagicMock()
82 get_resp.ok = True
83 get_resp.text = ")]}'\n" + json.dumps({"token": "unassign_xsrf_token"})
84
85 # Mock POST for unassign
86 post_resp = MagicMock()
87 post_resp.ok = True
88 post_resp.text = "" # 204 No Content typically
89
90 mock_session.request.side_effect = [get_resp, post_resp]
91
92 client.unassign("my_endpoint")
93
94 assert mock_session.request.call_count == 2
95 last_call_args = mock_session.request.call_args_list[1]
96 assert (
97 last_call_args.kwargs["headers"]["X-Goog-Colab-Token"] == "unassign_xsrf_token"
98 )
99 assert "unassign/my_endpoint" in last_call_args.args[1]
100
101
102 def test_client_assign_existing(client, mock_session):
103 # Mock _get_assignment (GET) returning existing Assignment
104 get_resp = MagicMock()
105 get_resp.ok = True
106 get_resp.text = ")]}'\n" + json.dumps(
107 {
108 "endpoint": "existing_endpoint",
109 "runtimeProxyInfo": {
110 "token": "existing_token",
111 "tokenExpiresInSeconds": 3600,
112 "url": "http://existing-backend",
113 },
114 }
115 )
116
117 mock_session.request.return_value = get_resp
118
119 res = client.assign(uuid.uuid4())
120
121 assert isinstance(res, Assignment)
122 assert res.endpoint == "existing_endpoint"
123 assert mock_session.request.call_count == 1
124
125
126 def test_client_list_assignments(client, mock_session):
127 # Mock list_assignments (GET)
128 resp = MagicMock()
129 resp.ok = True
130 resp.text = ")]}'\n" + json.dumps(
131 {
132 "assignments": [
133 {
134 "accelerator": "NONE",
135 "endpoint": "e1",
136 "variant": 0,
137 "machineShape": 0,
138 "runtimeProxyInfo": {
139 "token": "t1",
140 "tokenExpiresInSeconds": 3600,
141 "url": "u1",
142 },
143 }
144 ]
145 }
146 )
147
148 mock_session.request.return_value = resp
149
150 # This should fail if list_assignments is not implemented
151 res = client.list_assignments()
152
153 assert len(res) == 1
154 assert res[0].endpoint == "e1"
155 assert "tun/m/assignments" in mock_session.request.call_args.args[1]
156
157
158 def test_client_keep_alive_assignment_handles_empty_response(client, mock_session):
159 """The tunnel keep-alive ping returns an empty body. With no `schema=`,
160 _issue_request must short-circuit and not attempt to parse it."""
161 resp = MagicMock()
162 resp.ok = True
163 resp.text = ""
164 mock_session.request.return_value = resp
165
166 # Should NOT raise.
167 result = client.keep_alive_assignment("m-s-test")
168 assert result is None # no schema, so no return value
169
170
171 def test_client_keep_alive_assignment_request_shape(client, mock_session):
172 """Keep-alive is a Tunnel Frontend (TFE) HTTP ping, NOT the
173 `colab.pa.googleapis.com` RuntimeService RPC.
174
175 Background: the RuntimeService RPC requires the caller to be a
176 serviceusage consumer of Colab's internal project (1014160490159), which
177 no ordinary user account is. That path returned HTTP 403
178 USER_PROJECT_DENIED for every external user (issue #14). The official
179 Colab clients (and the colab-vscode extension) keep assignments alive via
180 a TFE-intercepted GET that only needs the user's own Gaia bearer token:
181
182 GET https://colab.research.google.com/tun/m/<endpoint>/keep-alive/
183 X-Colab-Tunnel: Google
184
185 TFE records LastActiveTime before forwarding, so the request keeps the VM
186 from being idle-pruned. This test pins that wire format.
187 """
188 resp = MagicMock()
189 resp.ok = True
190 resp.text = ""
191 mock_session.request.return_value = resp
192
193 client.keep_alive_assignment("m-s-test-endpoint")
194
195 assert mock_session.request.call_count == 1
196 call = mock_session.request.call_args
197 method, url = call.args[0], call.args[1]
198 headers = call.kwargs["headers"]
199
200 assert method == "GET"
201 # TFE tunnel keep-alive path on the session backend host.
202 assert url.endswith("/tun/m/m-s-test-endpoint/keep-alive/")
203 assert "colab.research.google.com" in url
204 # The request must be resolved through the Colab tunnel; without this
205 # header the front-door rejects the request with HTTP 400.
206 assert headers["X-Colab-Tunnel"] == "Google"
207 # Must NOT hit the RuntimeService / pa.googleapis.com path anymore.
208 assert "pa.googleapis.com" not in url
209 assert "KeepAliveAssignment" not in url
210 # No fire-and-forget JSON body; this is a plain GET.
211 assert "json" not in call.kwargs
212 # A short timeout is supplied so the daemon stays responsive on its cadence.
213 assert call.kwargs.get("timeout") is not None
214
215
216 def test_client_keep_alive_assignment_treats_read_timeout_as_success(
217 client, mock_session
218 ):
219 """TFE records activity as soon as the request arrives, then forwards to a
220 VM that may not respond — so the request commonly read-times-out even
221 though the keep-alive succeeded. A ReadTimeout must NOT propagate as an
222 error (otherwise the daemon would log spurious keep_alive_error events)."""
223 import requests
224
225 mock_session.request.side_effect = requests.exceptions.ReadTimeout("timed out")
226
227 # Should NOT raise.
228 result = client.keep_alive_assignment("m-s-test-endpoint")
229 assert result is None
230
231
232 def test_client_keep_alive_assignment_propagates_http_error(client, mock_session):
233 """A genuine HTTP error (e.g. 404 for a deleted assignment) must still
234 surface so the daemon can react (e.g. stop after consecutive 4xx)."""
235 from colab_cli.client import ColabRequestError
236
237 resp = MagicMock()
238 resp.ok = False
239 resp.status_code = 404
240 resp.reason = "Not Found"
241 resp.text = "gone"
242 mock_session.request.return_value = resp
243
244 with pytest.raises(ColabRequestError):
245 client.keep_alive_assignment("m-s-test-endpoint")
246
247
248 def test_client_assign_url_includes_shape_hm(client, mock_session):
249 get_resp = MagicMock()
250 get_resp.ok = True
251 get_resp.text = ")]}'\n" + json.dumps(
252 {"acc": "NONE", "nbh": "some_nbh", "token": "xsrf_token", "variant": "DEFAULT"}
253 )
254 post_resp = MagicMock()
255 post_resp.ok = True
256 post_resp.text = ")]}'\n" + json.dumps(
257 {
258 "accelerator": "A100",
259 "endpoint": "new_endpoint",
260 "runtimeProxyInfo": {
261 "token": "proxy_token",
262 "tokenExpiresInSeconds": 3600,
263 "url": "http://backend",
264 },
265 "variant": 1,
266 }
267 )
268 mock_session.request.side_effect = [get_resp, post_resp]
269
270 client.assign(
271 uuid.uuid4(),
272 variant=Variant.GPU,
273 accelerator=Accelerator.A100,
274 shape=Shape.HIGH_RAM,
275 )
276
277 get_url = mock_session.request.call_args_list[0].args[1]
278 assert "shape=hm" in get_url
279 post_url = mock_session.request.call_args_list[1].args[1]
280 assert "shape=hm" in post_url
281
282
283 def test_client_assign_url_omits_shape_for_standard(client, mock_session):
284 get_resp = MagicMock()
285 get_resp.ok = True
286 get_resp.text = ")]}'\n" + json.dumps(
287 {"acc": "NONE", "nbh": "some_nbh", "token": "xsrf_token", "variant": "DEFAULT"}
288 )
289 post_resp = MagicMock()
290 post_resp.ok = True
291 post_resp.text = ")]}'\n" + json.dumps(
292 {
293 "accelerator": "NONE",
294 "endpoint": "new_endpoint",
295 "runtimeProxyInfo": {
296 "token": "proxy_token",
297 "tokenExpiresInSeconds": 3600,
298 "url": "http://backend",
299 },
300 "variant": 0,
301 }
302 )
303 mock_session.request.side_effect = [get_resp, post_resp]
304
305 client.assign(uuid.uuid4())
306
307 get_url = mock_session.request.call_args_list[0].args[1]
308 assert "shape=" not in get_url
309
310
311 @pytest.mark.parametrize(
312 "accelerator,high_mem,expected",
313 [
314 (Accelerator.T4, True, Shape.HIGH_RAM),
315 (Accelerator.A100, True, Shape.HIGH_RAM),
316 (Accelerator.NONE, True, Shape.HIGH_RAM),
317 (Accelerator.L4, True, None),
318 (Accelerator.V5E1, True, None),
319 (Accelerator.T4, False, None),
320 ],
321 )
322 def test_resolve_assign_shape(accelerator, high_mem, expected):
323 assert resolve_assign_shape(accelerator, high_mem=high_mem) == expected