ssh-licco 2.5.2__tar.gz → 2.6.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {ssh_licco-2.5.2/ssh_licco.egg-info → ssh_licco-2.6.0}/PKG-INFO +1 -1
- {ssh_licco-2.5.2 → ssh_licco-2.6.0}/pyproject.toml +2 -1
- {ssh_licco-2.5.2 → ssh_licco-2.6.0/ssh_licco.egg-info}/PKG-INFO +1 -1
- {ssh_licco-2.5.2 → ssh_licco-2.6.0}/ssh_licco.egg-info/SOURCES.txt +2 -1
- {ssh_licco-2.5.2 → ssh_licco-2.6.0}/ssh_mcp/__init__.py +2 -2
- {ssh_licco-2.5.2 → ssh_licco-2.6.0}/ssh_mcp/handlers/execute.py +58 -6
- {ssh_licco-2.5.2 → ssh_licco-2.6.0}/ssh_mcp/server.py +2 -2
- {ssh_licco-2.5.2 → ssh_licco-2.6.0}/ssh_mcp/session_manager.py +50 -0
- ssh_licco-2.6.0/tests/test_watchdog.py +181 -0
- {ssh_licco-2.5.2 → ssh_licco-2.6.0}/LICENSE +0 -0
- {ssh_licco-2.5.2 → ssh_licco-2.6.0}/README.md +0 -0
- {ssh_licco-2.5.2 → ssh_licco-2.6.0}/setup.cfg +0 -0
- {ssh_licco-2.5.2 → ssh_licco-2.6.0}/ssh_licco.egg-info/dependency_links.txt +0 -0
- {ssh_licco-2.5.2 → ssh_licco-2.6.0}/ssh_licco.egg-info/entry_points.txt +0 -0
- {ssh_licco-2.5.2 → ssh_licco-2.6.0}/ssh_licco.egg-info/requires.txt +0 -0
- {ssh_licco-2.5.2 → ssh_licco-2.6.0}/ssh_licco.egg-info/top_level.txt +0 -0
- {ssh_licco-2.5.2 → ssh_licco-2.6.0}/ssh_mcp/__main__.py +0 -0
- {ssh_licco-2.5.2 → ssh_licco-2.6.0}/ssh_mcp/audit_logger.py +0 -0
- {ssh_licco-2.5.2 → ssh_licco-2.6.0}/ssh_mcp/batch_executor.py +0 -0
- {ssh_licco-2.5.2 → ssh_licco-2.6.0}/ssh_mcp/cli.py +0 -0
- {ssh_licco-2.5.2 → ssh_licco-2.6.0}/ssh_mcp/clients/__init__.py +0 -0
- {ssh_licco-2.5.2 → ssh_licco-2.6.0}/ssh_mcp/clients/additional_clients.py +0 -0
- {ssh_licco-2.5.2 → ssh_licco-2.6.0}/ssh_mcp/clients/factory.py +0 -0
- {ssh_licco-2.5.2 → ssh_licco-2.6.0}/ssh_mcp/clients/interface.py +0 -0
- {ssh_licco-2.5.2 → ssh_licco-2.6.0}/ssh_mcp/clients/paramiko_client.py +0 -0
- {ssh_licco-2.5.2 → ssh_licco-2.6.0}/ssh_mcp/config_manager.py +0 -0
- {ssh_licco-2.5.2 → ssh_licco-2.6.0}/ssh_mcp/connection_config.py +0 -0
- {ssh_licco-2.5.2 → ssh_licco-2.6.0}/ssh_mcp/connection_pool.py +0 -0
- {ssh_licco-2.5.2 → ssh_licco-2.6.0}/ssh_mcp/exceptions.py +0 -0
- {ssh_licco-2.5.2 → ssh_licco-2.6.0}/ssh_mcp/executor.py +0 -0
- {ssh_licco-2.5.2 → ssh_licco-2.6.0}/ssh_mcp/handlers/__init__.py +0 -0
- {ssh_licco-2.5.2 → ssh_licco-2.6.0}/ssh_mcp/handlers/connect.py +0 -0
- {ssh_licco-2.5.2 → ssh_licco-2.6.0}/ssh_mcp/handlers/context.py +0 -0
- {ssh_licco-2.5.2 → ssh_licco-2.6.0}/ssh_mcp/handlers/docker.py +0 -0
- {ssh_licco-2.5.2 → ssh_licco-2.6.0}/ssh_mcp/handlers/file_transfer.py +0 -0
- {ssh_licco-2.5.2 → ssh_licco-2.6.0}/ssh_mcp/handlers/host.py +0 -0
- {ssh_licco-2.5.2 → ssh_licco-2.6.0}/ssh_mcp/handlers/key.py +0 -0
- {ssh_licco-2.5.2 → ssh_licco-2.6.0}/ssh_mcp/handlers/process.py +0 -0
- {ssh_licco-2.5.2 → ssh_licco-2.6.0}/ssh_mcp/handlers/schemas.py +0 -0
- {ssh_licco-2.5.2 → ssh_licco-2.6.0}/ssh_mcp/handlers/session.py +0 -0
- {ssh_licco-2.5.2 → ssh_licco-2.6.0}/ssh_mcp/handlers/utils.py +0 -0
- {ssh_licco-2.5.2 → ssh_licco-2.6.0}/ssh_mcp/key_manager.py +0 -0
- {ssh_licco-2.5.2 → ssh_licco-2.6.0}/ssh_mcp/logging_config.py +0 -0
- {ssh_licco-2.5.2 → ssh_licco-2.6.0}/ssh_mcp/security.py +0 -0
- {ssh_licco-2.5.2 → ssh_licco-2.6.0}/ssh_mcp/service.py +0 -0
- {ssh_licco-2.5.2 → ssh_licco-2.6.0}/ssh_mcp/tunnel.py +0 -0
- {ssh_licco-2.5.2 → ssh_licco-2.6.0}/ssh_mcp/watchdog.py +0 -0
- {ssh_licco-2.5.2 → ssh_licco-2.6.0}/tests/test_config_manager.py +0 -0
- {ssh_licco-2.5.2 → ssh_licco-2.6.0}/tests/test_key_manager.py +0 -0
- {ssh_licco-2.5.2 → ssh_licco-2.6.0}/tests/test_security.py +0 -0
- {ssh_licco-2.5.2 → ssh_licco-2.6.0}/tests/test_server.py +0 -0
- {ssh_licco-2.5.2 → ssh_licco-2.6.0}/tests/test_session_manager.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "ssh-licco"
|
|
3
|
-
version = "2.
|
|
3
|
+
version = "2.6.0"
|
|
4
4
|
description = "SSH Model Context Protocol Server - Enable SSH functionality for AI models"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.10"
|
|
@@ -59,4 +59,5 @@ warn_unused_configs = true
|
|
|
59
59
|
disallow_untyped_defs = false
|
|
60
60
|
[tool.pytest.ini_options]
|
|
61
61
|
asyncio_mode = "auto"
|
|
62
|
+
asyncio_default_fixture_loop_scope = "function"
|
|
62
63
|
testpaths = ["tests"]
|
|
@@ -9,12 +9,12 @@ import uuid
|
|
|
9
9
|
|
|
10
10
|
from mcp.types import TextContent
|
|
11
11
|
|
|
12
|
-
from ..security import (
|
|
13
|
-
SecurityError,
|
|
14
|
-
command_validator,
|
|
15
|
-
format_backup_prompt,
|
|
16
|
-
generate_backup_command,
|
|
17
|
-
parse_deletion_command,
|
|
12
|
+
from ..security import (
|
|
13
|
+
SecurityError,
|
|
14
|
+
command_validator,
|
|
15
|
+
format_backup_prompt,
|
|
16
|
+
generate_backup_command,
|
|
17
|
+
parse_deletion_command,
|
|
18
18
|
)
|
|
19
19
|
from .connect import handle_connect
|
|
20
20
|
from .context import HandlerContext
|
|
@@ -152,9 +152,13 @@ Current security level: {os.getenv("SSH_SECURITY_LEVEL", "balanced")}""",
|
|
|
152
152
|
ctx.logger.warning(f"User chose to delete without backup: {command}")
|
|
153
153
|
|
|
154
154
|
if background:
|
|
155
|
+
ctx.logger.info(
|
|
156
|
+
f"[execute] background mode chosen for session={session_id} timeout={timeout}s"
|
|
157
|
+
)
|
|
155
158
|
return await execute_background(ctx, session_id, command, args, timeout)
|
|
156
159
|
|
|
157
160
|
# Normal execution
|
|
161
|
+
ctx.logger.info(f"[execute] normal mode session={session_id} timeout={timeout}s")
|
|
158
162
|
session = await ctx.session_manager.get_session(session_id)
|
|
159
163
|
if not session:
|
|
160
164
|
return [TextContent(type="text", text=f"Session not found: {session_id}")]
|
|
@@ -181,9 +185,17 @@ Current security level: {os.getenv("SSH_SECURITY_LEVEL", "balanced")}""",
|
|
|
181
185
|
stdin_data = sudo_pwd + "\n"
|
|
182
186
|
get_pty = True
|
|
183
187
|
|
|
188
|
+
ctx.logger.info(
|
|
189
|
+
f"[execute] invoking session.execute_command for session={session_id} "
|
|
190
|
+
f"timeout={timeout}s get_pty={get_pty}"
|
|
191
|
+
)
|
|
184
192
|
result = await session.execute_command(
|
|
185
193
|
command, timeout=timeout, stdin_data=stdin_data, get_pty=get_pty
|
|
186
194
|
)
|
|
195
|
+
ctx.logger.info(
|
|
196
|
+
f"[execute] session.execute_command returned for session={session_id} "
|
|
197
|
+
f"exit_code={result.get('exit_code')}"
|
|
198
|
+
)
|
|
187
199
|
|
|
188
200
|
if ctx.audit:
|
|
189
201
|
session_info = await ctx.session_manager.get_session(session_id)
|
|
@@ -214,6 +226,10 @@ async def execute_background(
|
|
|
214
226
|
ctx: HandlerContext, session_id: str, command: str, args: dict, timeout: int
|
|
215
227
|
) -> list[TextContent]:
|
|
216
228
|
"""Execute a command as a background task with proper nohup + bash -c wrapping."""
|
|
229
|
+
ctx.logger.info(
|
|
230
|
+
f"[execute-background] starting task session={session_id} timeout={timeout}s "
|
|
231
|
+
f"command={command[:200]}"
|
|
232
|
+
)
|
|
217
233
|
workdir = args.get("workdir", "/tmp")
|
|
218
234
|
log_file = args.get("log_file", "/tmp/background_task.log")
|
|
219
235
|
wait = args.get("wait", False)
|
|
@@ -298,10 +314,22 @@ async def execute_background(
|
|
|
298
314
|
try:
|
|
299
315
|
# Execute as a single command (background=False) — the wrapper
|
|
300
316
|
# includes sleep + status check, so it returns in ~1 second.
|
|
317
|
+
ctx.logger.info(
|
|
318
|
+
f"[execute-background] launching nohup wrapper for session={session_id} "
|
|
319
|
+
f"task_id={task_id} timeout={timeout + 5}s"
|
|
320
|
+
)
|
|
301
321
|
result = await ctx.session_manager.execute_command(
|
|
302
322
|
session_id, check_cmd, timeout=timeout + 5, background=False
|
|
303
323
|
)
|
|
324
|
+
ctx.logger.info(
|
|
325
|
+
f"[execute-background] wrapper returned for session={session_id} "
|
|
326
|
+
f"task_id={task_id} exit_code={result.get('exit_code')}"
|
|
327
|
+
)
|
|
304
328
|
except Exception as e:
|
|
329
|
+
ctx.logger.error(
|
|
330
|
+
f"[execute-background] failed to start task for session={session_id} "
|
|
331
|
+
f"task_id={task_id}: {type(e).__name__}: {e}"
|
|
332
|
+
)
|
|
305
333
|
return [TextContent(type="text", text=f"Error starting background task: {str(e)}")]
|
|
306
334
|
|
|
307
335
|
start_stdout = (result.get("stdout") or "").strip()
|
|
@@ -328,6 +356,11 @@ async def execute_background(
|
|
|
328
356
|
elif in_log:
|
|
329
357
|
log_tail += line + "\n"
|
|
330
358
|
|
|
359
|
+
ctx.logger.info(
|
|
360
|
+
f"[execute-background] parsed status for session={session_id} "
|
|
361
|
+
f"task_id={task_id} pid={pid} status={status} exit_code_str={exit_code_str}"
|
|
362
|
+
)
|
|
363
|
+
|
|
331
364
|
# ── 状态判定:区分 5 种情况 ──
|
|
332
365
|
#
|
|
333
366
|
# 1. ✅ SUCCESS — STATUS=COMPLETED, EXIT=0 命令执行成功
|
|
@@ -540,6 +573,11 @@ async def wait_for_task_completion(
|
|
|
540
573
|
elapsed = 0
|
|
541
574
|
interval = 2
|
|
542
575
|
|
|
576
|
+
ctx.logger.info(
|
|
577
|
+
f"[wait-task] start waiting session={session_id} task_id={task_id} "
|
|
578
|
+
f"timeout={timeout}s log_file={log_file}"
|
|
579
|
+
)
|
|
580
|
+
|
|
543
581
|
while elapsed < timeout:
|
|
544
582
|
await asyncio.sleep(interval)
|
|
545
583
|
elapsed += interval
|
|
@@ -552,6 +590,12 @@ async def wait_for_task_completion(
|
|
|
552
590
|
result = await ctx.session_manager.execute_command(session_id, check_cmd, timeout=10)
|
|
553
591
|
status = result.get("stdout", "").strip()
|
|
554
592
|
|
|
593
|
+
if elapsed % 10 == 0:
|
|
594
|
+
ctx.logger.debug(
|
|
595
|
+
f"[wait-task] heartbeat session={session_id} task_id={task_id} "
|
|
596
|
+
f"elapsed={elapsed}s status={status}"
|
|
597
|
+
)
|
|
598
|
+
|
|
555
599
|
if status == "COMPLETED" or status == "NOT_FOUND":
|
|
556
600
|
# 读取退出码
|
|
557
601
|
exit_result = await ctx.session_manager.execute_command(
|
|
@@ -569,6 +613,10 @@ async def wait_for_task_completion(
|
|
|
569
613
|
status_label = (
|
|
570
614
|
"Completed Successfully" if exit_code == 0 else f"Failed (exit code {exit_code})"
|
|
571
615
|
)
|
|
616
|
+
ctx.logger.info(
|
|
617
|
+
f"[wait-task] completed session={session_id} task_id={task_id} "
|
|
618
|
+
f"elapsed={elapsed}s exit_code={exit_code}"
|
|
619
|
+
)
|
|
572
620
|
return f"""Task {status_label}!
|
|
573
621
|
|
|
574
622
|
Task ID: {task_id}
|
|
@@ -581,6 +629,10 @@ Command Output:
|
|
|
581
629
|
{log_content}
|
|
582
630
|
"""
|
|
583
631
|
|
|
632
|
+
ctx.logger.warning(
|
|
633
|
+
f"[wait-task] timeout session={session_id} task_id={task_id} "
|
|
634
|
+
f"elapsed={elapsed}s timeout={timeout}s"
|
|
635
|
+
)
|
|
584
636
|
return f"""Task Still Running (Timeout)
|
|
585
637
|
|
|
586
638
|
Task ID: {task_id}
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import asyncio
|
|
4
|
-
import logging
|
|
5
4
|
import os
|
|
6
5
|
import shlex
|
|
7
6
|
import threading
|
|
@@ -20,6 +19,7 @@ from .connection_config import ConnectionConfig
|
|
|
20
19
|
from .handlers import HANDLERS, schemas
|
|
21
20
|
from .handlers.context import HandlerContext
|
|
22
21
|
from .key_manager import KeyManager
|
|
22
|
+
from .logging_config import get_logger
|
|
23
23
|
from .session_manager import SessionManager
|
|
24
24
|
|
|
25
25
|
try:
|
|
@@ -27,7 +27,7 @@ try:
|
|
|
27
27
|
except Exception:
|
|
28
28
|
from . import __version__
|
|
29
29
|
|
|
30
|
-
logger =
|
|
30
|
+
logger = get_logger(__name__)
|
|
31
31
|
|
|
32
32
|
|
|
33
33
|
class Tunnel:
|
|
@@ -210,7 +210,33 @@ class SSHSession:
|
|
|
210
210
|
async with self._connect_lock:
|
|
211
211
|
self._state = SessionState.EXECUTING
|
|
212
212
|
self._last_activity = datetime.now()
|
|
213
|
+
start_time = asyncio.get_event_loop().time()
|
|
214
|
+
heartbeat_task = None
|
|
215
|
+
truncated_cmd = command[:200].replace("\n", " ")
|
|
216
|
+
|
|
213
217
|
try:
|
|
218
|
+
self._logger.info(
|
|
219
|
+
f"[cmd-start] session={self.session_id} "
|
|
220
|
+
f"host={self.config.host}:{self.config.port} "
|
|
221
|
+
f"timeout={timeout}s background={background} get_pty={get_pty} "
|
|
222
|
+
f"command={truncated_cmd}"
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
async def _heartbeat() -> None:
|
|
226
|
+
try:
|
|
227
|
+
elapsed = 0
|
|
228
|
+
while True:
|
|
229
|
+
await asyncio.sleep(10)
|
|
230
|
+
elapsed += 10
|
|
231
|
+
self._logger.debug(
|
|
232
|
+
f"[cmd-heartbeat] session={self.session_id} "
|
|
233
|
+
f"elapsed={elapsed}s still executing: {truncated_cmd}"
|
|
234
|
+
)
|
|
235
|
+
except asyncio.CancelledError:
|
|
236
|
+
pass
|
|
237
|
+
|
|
238
|
+
heartbeat_task = asyncio.create_task(_heartbeat())
|
|
239
|
+
|
|
214
240
|
result = await self._executor.submit(
|
|
215
241
|
self._execute_command_sync,
|
|
216
242
|
command,
|
|
@@ -220,18 +246,42 @@ class SSHSession:
|
|
|
220
246
|
get_pty,
|
|
221
247
|
timeout=timeout + 5,
|
|
222
248
|
)
|
|
249
|
+
|
|
250
|
+
elapsed = asyncio.get_event_loop().time() - start_time
|
|
251
|
+
self._logger.info(
|
|
252
|
+
f"[cmd-done] session={self.session_id} elapsed={elapsed:.1f}s "
|
|
253
|
+
f"exit_code={result.get('exit_code')} command={truncated_cmd}"
|
|
254
|
+
)
|
|
223
255
|
return result
|
|
256
|
+
except asyncio.TimeoutError:
|
|
257
|
+
elapsed = asyncio.get_event_loop().time() - start_time
|
|
258
|
+
self._logger.error(
|
|
259
|
+
f"[cmd-timeout] session={self.session_id} elapsed={elapsed:.1f}s "
|
|
260
|
+
f"timeout={timeout}s command={truncated_cmd}"
|
|
261
|
+
)
|
|
262
|
+
raise
|
|
224
263
|
except ConnectionError:
|
|
225
264
|
# 连接错误,标记为断开状态
|
|
226
265
|
self._state = SessionState.DISCONNECTED
|
|
227
266
|
raise
|
|
228
267
|
except Exception as e:
|
|
268
|
+
elapsed = asyncio.get_event_loop().time() - start_time
|
|
269
|
+
self._logger.error(
|
|
270
|
+
f"[cmd-error] session={self.session_id} elapsed={elapsed:.1f}s "
|
|
271
|
+
f"error={type(e).__name__}: {e} command={truncated_cmd}"
|
|
272
|
+
)
|
|
229
273
|
# 其他异常也检查连接状态
|
|
230
274
|
if not self.is_connected:
|
|
231
275
|
self._state = SessionState.DISCONNECTED
|
|
232
276
|
raise ConnectionError(f"SSH connection lost during command execution: {str(e)}")
|
|
233
277
|
raise
|
|
234
278
|
finally:
|
|
279
|
+
if heartbeat_task is not None:
|
|
280
|
+
heartbeat_task.cancel()
|
|
281
|
+
try:
|
|
282
|
+
await heartbeat_task
|
|
283
|
+
except asyncio.CancelledError:
|
|
284
|
+
pass
|
|
235
285
|
if self.is_connected:
|
|
236
286
|
self._state = SessionState.CONNECTED
|
|
237
287
|
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import threading
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from unittest.mock import AsyncMock, MagicMock, patch
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
from ssh_mcp.watchdog import (
|
|
11
|
+
GlobalExceptionHandler,
|
|
12
|
+
Watchdog,
|
|
13
|
+
WatchdogEvent,
|
|
14
|
+
WatchdogStatus,
|
|
15
|
+
get_watchdog,
|
|
16
|
+
setup_global_exception_handler,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class TestWatchdogStatus:
|
|
21
|
+
def test_values(self):
|
|
22
|
+
assert WatchdogStatus.RUNNING.value == "running"
|
|
23
|
+
assert WatchdogStatus.STOPPED.value == "stopped"
|
|
24
|
+
assert WatchdogStatus.PAUSED.value == "paused"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class TestWatchdogEvent:
|
|
28
|
+
def test_creation(self):
|
|
29
|
+
event = WatchdogEvent(
|
|
30
|
+
timestamp=datetime.now(),
|
|
31
|
+
event_type="TEST",
|
|
32
|
+
message="test event",
|
|
33
|
+
)
|
|
34
|
+
assert event.event_type == "TEST"
|
|
35
|
+
assert event.message == "test event"
|
|
36
|
+
assert event.details == {}
|
|
37
|
+
|
|
38
|
+
def test_with_details(self):
|
|
39
|
+
event = WatchdogEvent(
|
|
40
|
+
timestamp=datetime.now(),
|
|
41
|
+
event_type="TEST",
|
|
42
|
+
message="test",
|
|
43
|
+
details={"key": "value"},
|
|
44
|
+
)
|
|
45
|
+
assert event.details["key"] == "value"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class TestWatchdog:
|
|
49
|
+
def setup_method(self):
|
|
50
|
+
Watchdog._instance = None
|
|
51
|
+
|
|
52
|
+
def _fresh_watchdog(self) -> Watchdog:
|
|
53
|
+
Watchdog._instance = None
|
|
54
|
+
return Watchdog()
|
|
55
|
+
|
|
56
|
+
def test_singleton(self):
|
|
57
|
+
w1 = Watchdog()
|
|
58
|
+
w2 = Watchdog()
|
|
59
|
+
assert w1 is w2
|
|
60
|
+
|
|
61
|
+
def test_initial_status_stopped(self):
|
|
62
|
+
w = self._fresh_watchdog()
|
|
63
|
+
assert w.status == WatchdogStatus.STOPPED
|
|
64
|
+
|
|
65
|
+
def test_register_task(self):
|
|
66
|
+
w = self._fresh_watchdog()
|
|
67
|
+
w.register_task("task-1", "Test Task", timeout=60)
|
|
68
|
+
tasks = w.get_tasks()
|
|
69
|
+
assert len(tasks) == 1
|
|
70
|
+
assert tasks[0].task_id == "task-1"
|
|
71
|
+
|
|
72
|
+
def test_unregister_task(self):
|
|
73
|
+
w = self._fresh_watchdog()
|
|
74
|
+
w.register_task("task-1", "Test Task")
|
|
75
|
+
w.unregister_task("task-1")
|
|
76
|
+
assert len(w.get_tasks()) == 0
|
|
77
|
+
|
|
78
|
+
def test_update_heartbeat(self):
|
|
79
|
+
w = self._fresh_watchdog()
|
|
80
|
+
w.register_task("task-1", "Test Task")
|
|
81
|
+
old_hb = w.get_tasks()[0].last_heartbeat
|
|
82
|
+
w.update_task_heartbeat("task-1")
|
|
83
|
+
new_hb = w.get_tasks()[0].last_heartbeat
|
|
84
|
+
assert new_hb >= old_hb
|
|
85
|
+
|
|
86
|
+
def test_update_heartbeat_nonexistent(self):
|
|
87
|
+
w = self._fresh_watchdog()
|
|
88
|
+
w.update_task_heartbeat("nonexistent")
|
|
89
|
+
|
|
90
|
+
def test_update_progress(self):
|
|
91
|
+
w = self._fresh_watchdog()
|
|
92
|
+
w.register_task("task-1", "Test Task")
|
|
93
|
+
w.update_task_progress("task-1", 50)
|
|
94
|
+
assert w.get_tasks()[0].progress == 50
|
|
95
|
+
|
|
96
|
+
def test_capture_exception(self):
|
|
97
|
+
w = self._fresh_watchdog()
|
|
98
|
+
w.capture_exception(ValueError("test error"), context="test")
|
|
99
|
+
events = w.get_events()
|
|
100
|
+
assert len(events) >= 1
|
|
101
|
+
assert events[0].event_type == "SYSTEM_ERROR"
|
|
102
|
+
|
|
103
|
+
def test_get_events_limit(self):
|
|
104
|
+
w = self._fresh_watchdog()
|
|
105
|
+
for i in range(5):
|
|
106
|
+
w.capture_exception(ValueError(f"error {i}"))
|
|
107
|
+
events = w.get_events(limit=3)
|
|
108
|
+
assert len(events) <= 3
|
|
109
|
+
|
|
110
|
+
def test_add_recovery_callback(self):
|
|
111
|
+
w = self._fresh_watchdog()
|
|
112
|
+
callback = MagicMock()
|
|
113
|
+
w.add_recovery_callback(callback)
|
|
114
|
+
assert callback in w._recovery_callbacks
|
|
115
|
+
|
|
116
|
+
def test_start_sets_running(self):
|
|
117
|
+
w = self._fresh_watchdog()
|
|
118
|
+
with patch.object(w, "_monitor_loop", MagicMock()):
|
|
119
|
+
with patch("asyncio.create_task", return_value=MagicMock()):
|
|
120
|
+
w.start()
|
|
121
|
+
assert w.status == WatchdogStatus.RUNNING
|
|
122
|
+
w.stop()
|
|
123
|
+
|
|
124
|
+
def test_stop_sets_stopped(self):
|
|
125
|
+
w = self._fresh_watchdog()
|
|
126
|
+
with patch.object(w, "_monitor_loop", MagicMock()):
|
|
127
|
+
with patch("asyncio.create_task", return_value=MagicMock()):
|
|
128
|
+
w.start()
|
|
129
|
+
w.stop()
|
|
130
|
+
assert w.status == WatchdogStatus.STOPPED
|
|
131
|
+
|
|
132
|
+
def test_start_already_running(self):
|
|
133
|
+
w = self._fresh_watchdog()
|
|
134
|
+
with patch.object(w, "_monitor_loop", MagicMock()):
|
|
135
|
+
with patch("asyncio.create_task", return_value=MagicMock()):
|
|
136
|
+
w.start()
|
|
137
|
+
with patch.object(w, "_monitor_loop", MagicMock()):
|
|
138
|
+
with patch("asyncio.create_task", return_value=MagicMock()):
|
|
139
|
+
w.start()
|
|
140
|
+
assert w.status == WatchdogStatus.RUNNING
|
|
141
|
+
w.stop()
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class TestGlobalExceptionHandler:
|
|
145
|
+
def test_enable(self):
|
|
146
|
+
handler = GlobalExceptionHandler()
|
|
147
|
+
mock_loop = MagicMock()
|
|
148
|
+
mock_loop.set_exception_handler = MagicMock()
|
|
149
|
+
mock_loop.get_exception_handler = MagicMock(return_value=None)
|
|
150
|
+
with patch("asyncio.get_event_loop", return_value=mock_loop):
|
|
151
|
+
handler.enable()
|
|
152
|
+
assert handler._enabled is True
|
|
153
|
+
|
|
154
|
+
def test_disable(self):
|
|
155
|
+
handler = GlobalExceptionHandler()
|
|
156
|
+
mock_loop = MagicMock()
|
|
157
|
+
mock_loop.set_exception_handler = MagicMock()
|
|
158
|
+
mock_loop.get_exception_handler = MagicMock(return_value=None)
|
|
159
|
+
mock_loop.set_exception_handler = MagicMock()
|
|
160
|
+
with patch("asyncio.get_event_loop", return_value=mock_loop):
|
|
161
|
+
handler.enable()
|
|
162
|
+
handler.disable()
|
|
163
|
+
assert handler._enabled is False
|
|
164
|
+
|
|
165
|
+
def test_disable_not_enabled(self):
|
|
166
|
+
handler = GlobalExceptionHandler()
|
|
167
|
+
handler.disable()
|
|
168
|
+
assert handler._enabled is False
|
|
169
|
+
|
|
170
|
+
def test_enable_already_enabled(self):
|
|
171
|
+
handler = GlobalExceptionHandler()
|
|
172
|
+
handler._enabled = True
|
|
173
|
+
handler.enable()
|
|
174
|
+
assert handler._enabled is True
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
class TestGetWatchdog:
|
|
178
|
+
def test_returns_watchdog(self):
|
|
179
|
+
Watchdog._instance = None
|
|
180
|
+
w = get_watchdog()
|
|
181
|
+
assert isinstance(w, Watchdog)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|