main
py 423 lines 15.8 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 from unittest.mock import MagicMock, patch, PropertyMock
16
17 import pytest
18 import typer
19 from colab_cli.client import ColabRequestError
20 from colab_cli.state import SessionState
21 from colab_cli.commands.session import new, stop, keep_alive
22
23
24 def test_session_state_with_pid():
25 s = SessionState(
26 name="test",
27 token="tok",
28 url="http://",
29 endpoint="end",
30 keep_alive_pid=1234,
31 )
32 data = s.model_dump()
33 assert data["keep_alive_pid"] == 1234
34
35 s2 = SessionState(**data)
36 assert s2.keep_alive_pid == 1234
37
38
39 @patch("colab_cli.commands.session.spawn_keep_alive")
40 def test_new_spawns_keep_alive(mock_spawn, mock_common_state):
41 # mock_common_state is automatically provided by conftest.py
42 mock_common_state.client.assign.return_value = MagicMock(
43 endpoint="e1", runtime_proxy_info=MagicMock(token="t1", url="u1")
44 )
45 mock_spawn.return_value = 9999
46
47 new(session="test-sess")
48
49 assert mock_spawn.called
50 # Endpoint and session name are positional; auth provider is propagated
51 # so the detached daemon uses the same provider as the parent (otherwise
52 # the daemon falls back to Typer's --auth=oauth2 default and silently
53 # uses the wrong auth backend — verified live 2026-04-30).
54 assert mock_spawn.call_args.args == ("e1", "test-sess")
55 assert "auth_provider" in mock_spawn.call_args.kwargs
56
57 # Verify PID is saved in state
58 assert mock_common_state.store.add.called
59 state_saved = mock_common_state.store.add.call_args[0][0]
60 assert state_saved.keep_alive_pid == 9999
61
62
63 def test_spawn_keep_alive_command_includes_auth_flag(mocker):
64 """spawn_keep_alive() must propagate `--auth=<provider>` as a global flag
65 BEFORE the `keep-alive` subcommand name. Without this, the detached child
66 falls back to the Typer default. Pin the exact arg ordering.
67 """
68 from colab_cli.auth import AuthProvider
69 from colab_cli.commands.session import spawn_keep_alive
70
71 mock_popen = mocker.patch("colab_cli.commands.session.subprocess.Popen")
72 mock_popen.return_value.pid = 12345
73
74 spawn_keep_alive("ep1", "sess1", auth_provider=AuthProvider.ADC)
75
76 cmd = mock_popen.call_args.args[0]
77 # Global flags must come before the subcommand name in Typer.
78 assert "--auth=adc" in cmd
79 auth_idx = cmd.index("--auth=adc")
80 keep_alive_idx = cmd.index("keep-alive")
81 assert auth_idx < keep_alive_idx, f"--auth must precede 'keep-alive' but got: {cmd}"
82 # Endpoint and session_name must follow `keep-alive` in order.
83 assert cmd[keep_alive_idx + 1] == "ep1"
84 assert cmd[keep_alive_idx + 2] == "sess1"
85
86
87 def test_spawn_keep_alive_command_includes_config_path(mocker):
88 """spawn_keep_alive() must propagate `--config <path>` as a global flag
89 so the daemon reads the same session state file as the parent. Without
90 this, a parent invoked with `--config /tmp/foo/sessions.json` writes
91 there but the daemon reads the default `~/.config/colab-cli/sessions.json`,
92 finds no session, and exits with `reason=session_not_found`. Discovered
93 while running the soak integration test 2026-04-30.
94 """
95 from colab_cli.commands.session import spawn_keep_alive
96
97 mock_popen = mocker.patch("colab_cli.commands.session.subprocess.Popen")
98 mock_popen.return_value.pid = 12345
99
100 spawn_keep_alive("ep1", "sess1", config_path="/tmp/test/sessions.json")
101
102 cmd = mock_popen.call_args.args[0]
103 assert "--config" in cmd
104 cfg_idx = cmd.index("--config")
105 assert cmd[cfg_idx + 1] == "/tmp/test/sessions.json"
106 keep_alive_idx = cmd.index("keep-alive")
107 assert cfg_idx < keep_alive_idx, (
108 f"--config must precede 'keep-alive' but got: {cmd}"
109 )
110
111
112 def test_spawn_keep_alive_omits_optional_flags_when_none(mocker):
113 """Backwards compat: callers that don't pass optional global flags get a
114 command line without them (the daemon uses Typer defaults)."""
115 from colab_cli.commands.session import spawn_keep_alive
116
117 mock_popen = mocker.patch("colab_cli.commands.session.subprocess.Popen")
118 mock_popen.return_value.pid = 12345
119
120 spawn_keep_alive("ep1", "sess1")
121
122 cmd = mock_popen.call_args.args[0]
123 assert not any(c.startswith("--auth") for c in cmd)
124 assert "--config" not in cmd
125
126
127 @patch("colab_cli.commands.session.spawn_keep_alive")
128 def test_new_runs_keep_alive_preflight(mock_spawn, mock_common_state):
129 """`colab new` should pre-flight the keep-alive RPC before persisting the
130 session, so missing-scope failures are surfaced immediately rather than
131 silently after ~2 minutes."""
132 mock_common_state.client.assign.return_value = MagicMock(
133 endpoint="e1", runtime_proxy_info=MagicMock(token="t1", url="u1")
134 )
135 mock_spawn.return_value = 9999
136
137 new(session="test-sess")
138
139 mock_common_state.client.keep_alive_assignment.assert_called_once_with("e1")
140
141
142 @patch("colab_cli.commands.session.spawn_keep_alive")
143 def test_new_aborts_on_missing_scope(mock_spawn, mock_common_state):
144 """A 403 SCOPE_NOT_PERMITTED on pre-flight should:
145 - print actionable remediation,
146 - unassign the VM (so we don't leak a billable assignment),
147 - exit non-zero,
148 - and NOT spawn the keep-alive daemon or persist the session.
149 """
150 mock_common_state.client.assign.return_value = MagicMock(
151 endpoint="e1", runtime_proxy_info=MagicMock(token="t1", url="u1")
152 )
153 mock_response = MagicMock()
154 mock_response.status_code = 403
155 scope_error = ColabRequestError(
156 "Forbidden",
157 MagicMock(),
158 mock_response,
159 response_body=(
160 '[7,"Request had insufficient authentication scopes.",[["type.'
161 'googleapis.com/google.rpc.DebugInfo",[null,"Authentication error: '
162 "2; Error Details: {AuthType:7,ErrorCode:2,DebugInfo:gaia_mint_"
163 'exchange::SCOPE_NOT_PERMITTED}"]]]]'
164 ),
165 )
166 mock_common_state.client.keep_alive_assignment.side_effect = scope_error
167
168 with pytest.raises(typer.Exit) as excinfo:
169 new(session="test-sess")
170 assert excinfo.value.exit_code == 1
171
172 # We unassigned the VM we just created.
173 mock_common_state.client.unassign.assert_called_once_with("e1")
174 # We did NOT spawn the keep-alive daemon.
175 mock_spawn.assert_not_called()
176 # We did NOT persist the session.
177 mock_common_state.store.add.assert_not_called()
178
179
180 @patch("colab_cli.commands.session.spawn_keep_alive")
181 def test_new_tolerates_non_scope_preflight_error(mock_spawn, mock_common_state):
182 """Non-scope errors (e.g. transient 5xx, 400 from a different cause) on
183 pre-flight should NOT block session creation — the daemon will retry and
184 log via the existing keep_alive_error path.
185 """
186 mock_common_state.client.assign.return_value = MagicMock(
187 endpoint="e1", runtime_proxy_info=MagicMock(token="t1", url="u1")
188 )
189 mock_response = MagicMock()
190 mock_response.status_code = 503
191 mock_common_state.client.keep_alive_assignment.side_effect = ColabRequestError(
192 "Service Unavailable",
193 MagicMock(),
194 mock_response,
195 response_body="upstream timeout",
196 )
197 mock_spawn.return_value = 9999
198
199 new(session="test-sess")
200
201 # We did NOT unassign — the session is still usable.
202 mock_common_state.client.unassign.assert_not_called()
203 # Daemon spawned and session persisted. `store.add` is called twice in
204 # the daemon-spawning path: once BEFORE spawn (so the daemon's initial
205 # session-existence check doesn't race), and once AFTER to record the
206 # keep_alive_pid. Final state must include the PID.
207 mock_spawn.assert_called_once()
208 assert mock_common_state.store.add.call_count == 2
209 final_state = mock_common_state.store.add.call_args.args[0]
210 assert final_state.keep_alive_pid == 9999
211
212
213 @patch("colab_cli.common.kill_process")
214 def test_stop_kills_keep_alive(mock_kill, mock_common_state):
215 mock_common_state.resolve_session.return_value = "test-sess"
216 mock_common_state.store.get.return_value = SessionState(
217 name="test-sess", token="t1", url="u1", endpoint="e1", keep_alive_pid=9999
218 )
219
220 stop(session="test-sess")
221
222 mock_kill.assert_called_once_with(9999)
223
224
225 def test_keep_alive_loop_basic(mock_common_state):
226 mock_common_state.store.get.return_value = SessionState(
227 name="test", token="t", url="u", endpoint="e1"
228 )
229
230 with (
231 patch("time.sleep", side_effect=InterruptedError),
232 patch("time.time", side_effect=[0, 100]),
233 ):
234 with pytest.raises(InterruptedError):
235 keep_alive("e1", "test")
236
237 mock_common_state.client.keep_alive_assignment.assert_called_once_with("e1")
238
239
240 def test_keep_alive_exits_on_consecutive_4xx(mock_common_state):
241 # Mock response for 404 error
242 mock_response = MagicMock()
243 mock_response.status_code = 404
244 error = ColabRequestError("Not Found", MagicMock(), mock_response)
245
246 mock_common_state.store.get.return_value = SessionState(
247 name="test", token="t", url="u", endpoint="e1"
248 )
249 mock_common_state.client.keep_alive_assignment.side_effect = error
250
251 with (
252 patch("time.sleep") as mock_sleep,
253 patch("time.time", side_effect=range(0, 10000, 60)),
254 ):
255 # It should exit after 2 calls to ping (consecutive 4xx)
256 # We'll use side_effect on mock_sleep to detect if it loops too much
257 mock_sleep.side_effect = [None, None, Exception("LoopTooLong")]
258
259 try:
260 keep_alive("e1", "test")
261 except Exception as e:
262 if str(e) == "LoopTooLong":
263 pytest.fail("Keep alive loop did not exit after consecutive 4xx")
264 raise
265
266 assert mock_common_state.client.keep_alive_assignment.call_count == 2
267
268
269 def test_keep_alive_resets_on_success(mock_common_state):
270 # Mock response for 404 error
271 mock_response_404 = MagicMock()
272 mock_response_404.status_code = 404
273 error_404 = ColabRequestError("Not Found", MagicMock(), mock_response_404)
274
275 mock_common_state.store.get.return_value = SessionState(
276 name="test", token="t", url="u", endpoint="e1"
277 )
278
279 # ping sequence: 404, success, 404, 404
280 mock_common_state.client.keep_alive_assignment.side_effect = [
281 error_404,
282 None,
283 error_404,
284 error_404,
285 ]
286
287 with (
288 patch("time.sleep") as mock_sleep,
289 patch("time.time", side_effect=range(0, 10000, 60)),
290 ):
291 # We need to make sure it doesn't loop forever
292 mock_sleep.side_effect = [None, None, None, Exception("StopLoop")]
293
294 try:
295 keep_alive("e1", "test")
296 except Exception as e:
297 if str(e) != "StopLoop":
298 raise
299
300
301 @patch("colab_cli.common.kill_process")
302 def test_sync_sessions_handles_lost_vm(mock_kill, mock_common_state):
303 # Server returns empty list (indicating VM is gone)
304 mock_common_state.client.list_assignments.return_value = []
305
306 lost_session = SessionState(
307 name="lost-sess", token="t1", url="u1", endpoint="e1", keep_alive_pid=7777
308 )
309 # Local session has a keep_alive_pid
310 mock_common_state.store.list.return_value = {"lost-sess": lost_session}
311 # Ensure store.get returns the session too
312 mock_common_state.store.get.return_value = lost_session
313
314 from colab_cli.common import State
315
316 real_state = State()
317
318 with (
319 patch.object(State, "store", new_callable=PropertyMock) as mock_store_prop,
320 patch.object(State, "client", new_callable=PropertyMock) as mock_client_prop,
321 patch.object(State, "history", new_callable=PropertyMock) as mock_hist_prop,
322 ):
323 mock_store_prop.return_value = mock_common_state.store
324 mock_client_prop.return_value = mock_common_state.client
325 mock_hist_prop.return_value = mock_common_state.history
326
327 real_state.sync_sessions()
328
329 mock_kill.assert_called_with(7777)
330
331
332 def test_keep_alive_logging(mock_common_state):
333 # Mock successful run that eventually hits time limit
334 mock_common_state.store.get.return_value = SessionState(
335 name="test", token="t", url="u", endpoint="e1"
336 )
337
338 # time.time() is called: start_time, loop-condition (force exit),
339 # and once at the end for duration_seconds calculation.
340 with (
341 patch("time.time", side_effect=[0, 24 * 3600 + 1, 24 * 3600 + 1]),
342 patch("time.sleep"),
343 ):
344 keep_alive("e1", "test")
345
346 # Verify logging
347 log_calls = mock_common_state.history.log_event.call_args_list
348 started = [c for c in log_calls if c.args[1] == "keep_alive_started"]
349 assert started, "expected keep_alive_started event"
350 assert started[0].args[2]["endpoint"] == "e1"
351 assert "pid" in started[0].args[2]
352
353 stopped = [c for c in log_calls if c.args[1] == "keep_alive_stopped"]
354 assert stopped, "expected keep_alive_stopped event"
355 payload = stopped[0].args[2]
356 assert payload["reason"] == "time_limit_reached"
357 assert "iterations" in payload
358 assert "duration_seconds" in payload
359
360
361 def test_keep_alive_logging_session_gone(mock_common_state):
362 # Session not found in store
363 mock_common_state.store.get.return_value = None
364
365 with patch("time.time", return_value=0), patch("time.sleep"):
366 keep_alive("e1", "test")
367
368 log_calls = mock_common_state.history.log_event.call_args_list
369 stopped = [c for c in log_calls if c.args[1] == "keep_alive_stopped"]
370 assert stopped, "expected keep_alive_stopped event"
371 payload = stopped[0].args[2]
372 assert payload["reason"] == "session_not_found"
373 assert payload["iterations"] == 1
374
375
376 def test_keep_alive_logs_endpoint_mismatch_details(mock_common_state):
377 # Session exists but endpoint has changed.
378 mock_common_state.store.get.return_value = SessionState(
379 name="test", token="t", url="u", endpoint="e2-new"
380 )
381
382 with patch("time.time", return_value=0), patch("time.sleep"):
383 keep_alive("e1-old", "test")
384
385 log_calls = mock_common_state.history.log_event.call_args_list
386 stopped = [c for c in log_calls if c.args[1] == "keep_alive_stopped"]
387 assert stopped, "expected keep_alive_stopped event"
388 payload = stopped[0].args[2]
389 assert payload["reason"] == "endpoint_mismatch"
390 assert payload["expected_endpoint"] == "e1-old"
391 assert payload["actual_endpoint"] == "e2-new"
392
393
394 def test_keep_alive_logs_error_events_and_last_error(mock_common_state):
395 # Two consecutive 4xx errors -> exit, with per-error events + last_error in stop.
396 mock_response = MagicMock()
397 mock_response.status_code = 404
398 error = ColabRequestError("Not Found", MagicMock(), mock_response)
399
400 mock_common_state.store.get.return_value = SessionState(
401 name="test", token="t", url="u", endpoint="e1"
402 )
403 mock_common_state.client.keep_alive_assignment.side_effect = error
404
405 with (
406 patch("time.sleep"),
407 patch("time.time", side_effect=range(0, 10000, 60)),
408 ):
409 keep_alive("e1", "test")
410
411 log_calls = mock_common_state.history.log_event.call_args_list
412
413 errors = [c for c in log_calls if c.args[1] == "keep_alive_error"]
414 assert len(errors) == 2, "expected one keep_alive_error per failed ping"
415 assert errors[0].args[2]["status_code"] == 404
416 assert errors[0].args[2]["error_type"] == "ColabRequestError"
417
418 stopped = [c for c in log_calls if c.args[1] == "keep_alive_stopped"]
419 assert stopped, "expected keep_alive_stopped event"
420 payload = stopped[0].args[2]
421 assert payload["reason"] == "consecutive_4xx_errors"
422 assert payload["last_error"]["status_code"] == 404
423 assert payload["last_error"]["error_type"] == "ColabRequestError"