standkit 0.3.7__py3-none-any.whl
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.
- standkit/__init__.py +11 -0
- standkit/health.py +163 -0
- standkit/lifecycle.py +191 -0
- standkit/logs.py +221 -0
- standkit/models.py +211 -0
- standkit/platform.py +174 -0
- standkit/registry.py +223 -0
- standkit/secrets.py +144 -0
- standkit-0.3.7.dist-info/METADATA +155 -0
- standkit-0.3.7.dist-info/RECORD +38 -0
- standkit-0.3.7.dist-info/WHEEL +5 -0
- standkit-0.3.7.dist-info/entry_points.txt +4 -0
- standkit-0.3.7.dist-info/licenses/LICENSE +21 -0
- standkit-0.3.7.dist-info/top_level.txt +3 -0
- standkit_agent/__init__.py +9 -0
- standkit_agent/__main__.py +192 -0
- standkit_agent/audit.py +93 -0
- standkit_agent/security.py +300 -0
- standkit_agent/server.py +386 -0
- standkit_hub/__init__.py +13 -0
- standkit_hub/__main__.py +131 -0
- standkit_hub/agent_control.py +216 -0
- standkit_hub/assets/bpmkit-icon.ico +0 -0
- standkit_hub/assets/icon.png +0 -0
- standkit_hub/client.py +159 -0
- standkit_hub/config.py +156 -0
- standkit_hub/logs_browser.py +174 -0
- standkit_hub/redis_min.py +326 -0
- standkit_hub/security.py +146 -0
- standkit_hub/server.py +881 -0
- standkit_hub/shortcut.py +264 -0
- standkit_hub/web/app.js +803 -0
- standkit_hub/web/bpmkit-logo-dark.svg +5 -0
- standkit_hub/web/bpmkit-logo.svg +5 -0
- standkit_hub/web/favicon.png +0 -0
- standkit_hub/web/favicon.svg +4 -0
- standkit_hub/web/index.html +267 -0
- standkit_hub/web/style.css +843 -0
standkit/__init__.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""
|
|
2
|
+
standkit — свободное (MIT) ядро управления жизненным циклом стендов BPMSoft.
|
|
3
|
+
|
|
4
|
+
Пакет не содержит лицензионно-чувствительного контента платформы BPMSoft и не
|
|
5
|
+
зависит от сторонних библиотек (stdlib-only). Полная документация — см. README.md
|
|
6
|
+
и docs/ARCHITECTURE.md в корне репозитория.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
__version__ = "0.3.7"
|
|
10
|
+
|
|
11
|
+
__all__ = ["__version__"]
|
standkit/health.py
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Health-пробы стенда: жив ли процесс, отвечает ли HTTP, открыт ли TCP-порт
|
|
3
|
+
(используется как поверхностная проверка живости БД/Redis).
|
|
4
|
+
|
|
5
|
+
Все пробы — быстрые и не требуют сторонних зависимостей (только stdlib:
|
|
6
|
+
``socket``, ``urllib``). Глубокие проверки (реальный SQL-запрос к БД, PING к
|
|
7
|
+
Redis по протоколу) — сознательно вынесены в TODO под опциональный флаг,
|
|
8
|
+
чтобы базовый health-чек оставался лёгким и не тянул psycopg2/pyodbc/redis-py
|
|
9
|
+
в обязательные зависимости ядра.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import socket
|
|
15
|
+
import urllib.error
|
|
16
|
+
import urllib.request
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Optional
|
|
19
|
+
|
|
20
|
+
from standkit.models import ProbeState, Stand, StandStatus
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def process_alive(pidfile: Path) -> bool:
|
|
24
|
+
"""
|
|
25
|
+
Проверяет, жив ли процесс, чей pid записан в ``pidfile``.
|
|
26
|
+
|
|
27
|
+
Если файла нет или он не читается — считается, что процесс не запущен.
|
|
28
|
+
Импортирует standkit.platform лениво, чтобы health.py можно было
|
|
29
|
+
использовать и для проверки "чужих" процессов без завязки на lifecycle.
|
|
30
|
+
"""
|
|
31
|
+
from standkit import platform as _platform # локальный импорт — избегаем цикла
|
|
32
|
+
|
|
33
|
+
if not pidfile.exists():
|
|
34
|
+
return False
|
|
35
|
+
try:
|
|
36
|
+
pid = int(pidfile.read_text(encoding="utf-8").strip())
|
|
37
|
+
except (ValueError, OSError):
|
|
38
|
+
return False
|
|
39
|
+
return _platform.is_alive(pid)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def process_running(
|
|
43
|
+
pidfile: Optional[Path],
|
|
44
|
+
host: Optional[str] = None,
|
|
45
|
+
port: Optional[int] = None,
|
|
46
|
+
) -> bool:
|
|
47
|
+
"""
|
|
48
|
+
Считает процесс стенда "живым", если ЛИБО жив pidfile standkit (стенд
|
|
49
|
+
поднят самим ядром), ЛИБО слушается TCP-порт стенда (стенд поднят извне
|
|
50
|
+
standkit — вручную, через IIS/systemd/сторонний скрипт и т.п.).
|
|
51
|
+
|
|
52
|
+
Это расширение process_alive: тот проверяет только pidfile, этот —
|
|
53
|
+
комбинирует обе приметы живости, потому что реальные стенды часто
|
|
54
|
+
поднимаются не через lifecycle.start().
|
|
55
|
+
"""
|
|
56
|
+
if pidfile is not None and process_alive(pidfile):
|
|
57
|
+
return True
|
|
58
|
+
if host and port:
|
|
59
|
+
return tcp_open(host, port)
|
|
60
|
+
return False
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def http_ok(url: str, *, timeout: float = 3.0) -> bool:
|
|
64
|
+
"""
|
|
65
|
+
Проверяет, отвечает ли HTTP(S)-эндпоинт (любой код ответа < 500 считается
|
|
66
|
+
"живым" — стенд может честно отдавать 401/403 до логина, это не авария).
|
|
67
|
+
|
|
68
|
+
Сетевые ошибки (отказано в соединении, DNS, таймаут) → False, без исключений
|
|
69
|
+
наружу — это намеренно проба, а не операция, которая должна падать.
|
|
70
|
+
"""
|
|
71
|
+
try:
|
|
72
|
+
req = urllib.request.Request(url, method="GET")
|
|
73
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
74
|
+
return resp.status < 500
|
|
75
|
+
except urllib.error.HTTPError as exc:
|
|
76
|
+
# Сервер ответил (пусть и ошибкой) — значит, живой.
|
|
77
|
+
return exc.code < 500
|
|
78
|
+
except (urllib.error.URLError, TimeoutError, OSError, ValueError):
|
|
79
|
+
return False
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def tcp_open(host: str, port: int, *, timeout: float = 2.0) -> bool:
|
|
83
|
+
"""
|
|
84
|
+
Проверяет, открыт ли TCP-порт (используется как поверхностная liveness-проба
|
|
85
|
+
БД/Redis — не подменяет полноценный запрос к сервису).
|
|
86
|
+
"""
|
|
87
|
+
if not host or not port:
|
|
88
|
+
return False
|
|
89
|
+
try:
|
|
90
|
+
with socket.create_connection((host, port), timeout=timeout):
|
|
91
|
+
return True
|
|
92
|
+
except (OSError, ValueError):
|
|
93
|
+
return False
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def db_deep_check(stand: Stand) -> ProbeState:
|
|
97
|
+
"""
|
|
98
|
+
TODO(следующая итерация): полноценная проверка БД (реальный SELECT 1 через
|
|
99
|
+
psycopg2 для postgres / pyodbc для mssql). Требует опциональных
|
|
100
|
+
зависимостей, которые НЕ должны стать обязательными для ядра — включать
|
|
101
|
+
только по явному флагу вызывающей стороны.
|
|
102
|
+
|
|
103
|
+
Пока — заглушка, всегда возвращающая SKIPPED, чтобы вызывающий код мог
|
|
104
|
+
отличить "проверка не выполнялась" от "проверка провалилась".
|
|
105
|
+
"""
|
|
106
|
+
return ProbeState.SKIPPED
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def redis_deep_check(stand: Stand) -> ProbeState:
|
|
110
|
+
"""TODO(следующая итерация): полноценный PING к Redis (redis-py, опциональная зависимость)."""
|
|
111
|
+
return ProbeState.SKIPPED
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def check_stand(
|
|
115
|
+
stand: Stand,
|
|
116
|
+
*,
|
|
117
|
+
pidfile: Optional[Path] = None,
|
|
118
|
+
http_path: str = "/",
|
|
119
|
+
deep_db: bool = False,
|
|
120
|
+
deep_redis: bool = False,
|
|
121
|
+
) -> StandStatus:
|
|
122
|
+
"""
|
|
123
|
+
Собирает сводный StandStatus по всем доступным быстрым пробам.
|
|
124
|
+
|
|
125
|
+
``pidfile`` — если не передан, процесс-проба пропускается (UNKNOWN) —
|
|
126
|
+
вызывающая сторона (lifecycle) знает свой путь к pidfile лучше, чем этот
|
|
127
|
+
модуль по умолчанию.
|
|
128
|
+
"""
|
|
129
|
+
status = StandStatus(name=stand.name)
|
|
130
|
+
|
|
131
|
+
if pidfile is not None or (stand.stand_host and stand.stand_port):
|
|
132
|
+
is_up = process_running(pidfile, stand.stand_host, stand.stand_port)
|
|
133
|
+
status.process = ProbeState.OK if is_up else ProbeState.DOWN
|
|
134
|
+
else:
|
|
135
|
+
status.process = ProbeState.UNKNOWN
|
|
136
|
+
|
|
137
|
+
if stand.stand_host and stand.stand_port:
|
|
138
|
+
url = f"http://{stand.stand_host}:{stand.stand_port}{http_path}"
|
|
139
|
+
status.http = ProbeState.OK if http_ok(url) else ProbeState.DOWN
|
|
140
|
+
else:
|
|
141
|
+
status.http = ProbeState.UNKNOWN
|
|
142
|
+
|
|
143
|
+
if stand.db_host and stand.db_port:
|
|
144
|
+
status.db = ProbeState.OK if tcp_open(stand.db_host, stand.db_port) else ProbeState.DOWN
|
|
145
|
+
if deep_db:
|
|
146
|
+
status.db = db_deep_check(stand)
|
|
147
|
+
else:
|
|
148
|
+
status.db = ProbeState.UNKNOWN
|
|
149
|
+
|
|
150
|
+
redis_host = stand.extra.get("redis_host")
|
|
151
|
+
redis_port = stand.extra.get("redis_port")
|
|
152
|
+
if redis_host and redis_port:
|
|
153
|
+
status.redis = ProbeState.OK if tcp_open(redis_host, int(redis_port)) else ProbeState.DOWN
|
|
154
|
+
if deep_redis:
|
|
155
|
+
status.redis = redis_deep_check(stand)
|
|
156
|
+
else:
|
|
157
|
+
status.redis = ProbeState.UNKNOWN
|
|
158
|
+
|
|
159
|
+
# TODO: last_deploy — задел на будущее, источник данных пока не определён
|
|
160
|
+
# (кандидат — метаданные из BPMkit deploy_status/deploy_verify).
|
|
161
|
+
status.last_deploy = ProbeState.UNKNOWN
|
|
162
|
+
|
|
163
|
+
return status
|
standkit/lifecycle.py
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Жизненный цикл стенда: start/stop/restart поверх standkit.platform.
|
|
3
|
+
|
|
4
|
+
Хранит pid запущенного стенда в per-stand pidfile в рабочей папке (по умолчанию
|
|
5
|
+
``<домашняя папка пользователя>/.standkit/run/<имя стенда>.pid``), чтобы между
|
|
6
|
+
разными вызовами (в т.ч. из другого процесса Python — например, из
|
|
7
|
+
standkit_agent) можно было понять, жив ли стенд и как его остановить.
|
|
8
|
+
|
|
9
|
+
Работает только для ``transport == "local"``. Для ``transport == "agent"``
|
|
10
|
+
вызывающая сторона (GUI/клиент) должна ходить по HTTP к соответствующему
|
|
11
|
+
standkit_agent — этот модуль намеренно не содержит сетевого кода (см.
|
|
12
|
+
standkit_gui.client).
|
|
13
|
+
|
|
14
|
+
TODO(следующая итерация):
|
|
15
|
+
- polling готовности после start() (сейчас просто возвращает pid, готовность
|
|
16
|
+
веб-хоста нужно проверять отдельно через standkit.health.http_ok в цикле);
|
|
17
|
+
- блокировка pidfile от гонки двух одновременных start() одного стенда;
|
|
18
|
+
- восстановление после "потерянного" pidfile (процесс с этим pid уже другой —
|
|
19
|
+
нужна доп. проверка, например по имени командной строки).
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import shutil
|
|
25
|
+
import time
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import Optional
|
|
28
|
+
|
|
29
|
+
from standkit import platform as _platform
|
|
30
|
+
from standkit.models import Stand, Transport
|
|
31
|
+
|
|
32
|
+
_DEFAULT_RUN_DIR = Path.home() / ".standkit" / "run"
|
|
33
|
+
_DEFAULT_LOG_DIR = Path.home() / ".standkit" / "logs"
|
|
34
|
+
|
|
35
|
+
# Пауза после spawn_hidden() перед проверкой "жив ли процесс" — типичный
|
|
36
|
+
# симптом "тихого" провала старта: неверный dll/аргументы, .NET-хост пишет
|
|
37
|
+
# ошибку в лог и завершается за доли секунды, а spawn_hidden() успевает
|
|
38
|
+
# вернуть валидный pid до этого момента. Вынесено в параметр start(), чтобы
|
|
39
|
+
# тесты могли передать 0 и не ждать реальное время.
|
|
40
|
+
_DEFAULT_STARTUP_CHECK_DELAY = 0.6
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class LifecycleError(Exception):
|
|
44
|
+
"""Ошибки управления жизненным циклом стенда."""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def pidfile_path(stand: Stand, run_dir: Optional[Path] = None) -> Path:
|
|
48
|
+
"""Путь к pid-файлу стенда (по умолчанию — общая рабочая папка standkit пользователя)."""
|
|
49
|
+
run_dir = Path(run_dir) if run_dir else _DEFAULT_RUN_DIR
|
|
50
|
+
return run_dir / f"{stand.name}.pid"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def log_path(stand: Stand, log_dir: Optional[Path] = None) -> Path:
|
|
54
|
+
"""Путь к лог-файлу стенда (см. standkit.logs.tail)."""
|
|
55
|
+
log_dir = Path(log_dir) if log_dir else _DEFAULT_LOG_DIR
|
|
56
|
+
return log_dir / f"{stand.name}.log"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _read_pid(pf: Path) -> Optional[int]:
|
|
60
|
+
if not pf.exists():
|
|
61
|
+
return None
|
|
62
|
+
try:
|
|
63
|
+
return int(pf.read_text(encoding="utf-8").strip())
|
|
64
|
+
except (ValueError, OSError):
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _write_pid(pf: Path, pid: int) -> None:
|
|
69
|
+
pf.parent.mkdir(parents=True, exist_ok=True)
|
|
70
|
+
pf.write_text(str(pid), encoding="utf-8")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _require_local(stand: Stand) -> None:
|
|
74
|
+
if stand.transport != Transport.LOCAL:
|
|
75
|
+
raise LifecycleError(
|
|
76
|
+
f"Стенд '{stand.name}' имеет transport={stand.transport.value!r} — "
|
|
77
|
+
"управление headless-процессом доступно только для transport='local' "
|
|
78
|
+
"(для 'agent' используйте HTTP-клиент standkit_gui.client к соответствующему агенту)"
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _resolve_dotnet(dotnet: str) -> str:
|
|
83
|
+
"""
|
|
84
|
+
Резолвит команду ``dotnet`` записи стенда в конкретный исполняемый путь.
|
|
85
|
+
|
|
86
|
+
- Если ``dotnet`` уже указывает на существующий файл (абсолютный или
|
|
87
|
+
относительный путь) — используется как есть, PATH не требуется.
|
|
88
|
+
- Иначе (голое имя вроде ``"dotnet"``, дефолт по схеме ``Stand``) ищется
|
|
89
|
+
в PATH через ``shutil.which``.
|
|
90
|
+
|
|
91
|
+
Раньше отсутствие ``dotnet`` в PATH приводило к "тихому" провалу —
|
|
92
|
+
subprocess.Popen падал где-то внутри spawn_hidden() с малопонятной
|
|
93
|
+
OSError, либо (на некоторых системах) вообще не поднимал процесс без
|
|
94
|
+
видимой ошибки в UI хаба. Явная проверка здесь даёт понятный текст ДО
|
|
95
|
+
попытки спавна.
|
|
96
|
+
"""
|
|
97
|
+
candidate = Path(dotnet)
|
|
98
|
+
if candidate.exists():
|
|
99
|
+
return str(candidate)
|
|
100
|
+
resolved = shutil.which(dotnet)
|
|
101
|
+
if resolved is None:
|
|
102
|
+
raise LifecycleError(
|
|
103
|
+
f"dotnet не найден в PATH: {dotnet!r} — установите .NET SDK/Runtime "
|
|
104
|
+
"или укажите полный путь в поле 'dotnet' записи реестра стенда"
|
|
105
|
+
)
|
|
106
|
+
return resolved
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def start(
|
|
110
|
+
stand: Stand,
|
|
111
|
+
*,
|
|
112
|
+
run_dir: Optional[Path] = None,
|
|
113
|
+
log_dir: Optional[Path] = None,
|
|
114
|
+
startup_check_delay: float = _DEFAULT_STARTUP_CHECK_DELAY,
|
|
115
|
+
) -> int:
|
|
116
|
+
"""
|
|
117
|
+
Запускает стенд headless-процессом, если он ещё не запущен.
|
|
118
|
+
|
|
119
|
+
Возвращает pid процесса (существующего, если стенд уже был жив, либо
|
|
120
|
+
только что созданного). Бросает ``LifecycleError`` с понятным текстом,
|
|
121
|
+
если ``dotnet`` не резолвится (см. ``_resolve_dotnet``) либо процесс
|
|
122
|
+
завершился сразу после старта (см. проверку ``is_alive`` ниже) — раньше
|
|
123
|
+
в обоих случаях start() мог тихо "вернуть успех", хотя стенд не поднялся.
|
|
124
|
+
|
|
125
|
+
Команда запуска — ``dotnet <stand_dll>`` в ``stand_dir``, БЕЗ аргументов
|
|
126
|
+
командной строки: BPMSoft.WebHost их не принимает (свой CommandLineParser),
|
|
127
|
+
адрес/порт берётся из конфига стенда. Доп. окружение (ASPNETCORE_ENVIRONMENT
|
|
128
|
+
и т.п.) при необходимости — зона расширения следующей итерации.
|
|
129
|
+
"""
|
|
130
|
+
_require_local(stand)
|
|
131
|
+
|
|
132
|
+
pf = pidfile_path(stand, run_dir)
|
|
133
|
+
existing = _read_pid(pf)
|
|
134
|
+
if existing and _platform.is_alive(existing):
|
|
135
|
+
return existing
|
|
136
|
+
|
|
137
|
+
stand_dir = Path(stand.stand_dir)
|
|
138
|
+
if not stand_dir.exists():
|
|
139
|
+
raise LifecycleError(f"Каталог стенда не найден: {stand_dir}")
|
|
140
|
+
|
|
141
|
+
dotnet_path = _resolve_dotnet(stand.dotnet)
|
|
142
|
+
# BPMSoft.WebHost парсит аргументы СВОИМ CommandLineParser и НЕ понимает
|
|
143
|
+
# ASP.NET-флаги вроде --urls (ошибка «Verb '--urls' is not recognized»).
|
|
144
|
+
# Адрес/порт стенд берёт из собственного конфига (appsettings). Поэтому
|
|
145
|
+
# запускаем без доп. аргументов — просто dotnet <stand_dll> в каталоге
|
|
146
|
+
# стенда (так же, как это делает сам BPMkit/кит).
|
|
147
|
+
cmd = [dotnet_path, stand.stand_dll]
|
|
148
|
+
lp = log_path(stand, log_dir)
|
|
149
|
+
pid = _platform.spawn_hidden(cmd, cwd=stand_dir, log_path=lp)
|
|
150
|
+
|
|
151
|
+
if startup_check_delay > 0:
|
|
152
|
+
time.sleep(startup_check_delay)
|
|
153
|
+
if not _platform.is_alive(pid):
|
|
154
|
+
raise LifecycleError(
|
|
155
|
+
f"процесс стенда '{stand.name}' завершился сразу после старта — "
|
|
156
|
+
f"смотрите логи ({lp})"
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
_write_pid(pf, pid)
|
|
160
|
+
return pid
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def stop(stand: Stand, *, run_dir: Optional[Path] = None) -> bool:
|
|
164
|
+
"""Останавливает стенд, если он запущен. Возвращает True при успешной остановке."""
|
|
165
|
+
_require_local(stand)
|
|
166
|
+
|
|
167
|
+
pf = pidfile_path(stand, run_dir)
|
|
168
|
+
pid = _read_pid(pf)
|
|
169
|
+
if pid is None:
|
|
170
|
+
return True
|
|
171
|
+
|
|
172
|
+
stopped = _platform.stop(pid)
|
|
173
|
+
if stopped:
|
|
174
|
+
try:
|
|
175
|
+
pf.unlink(missing_ok=True)
|
|
176
|
+
except OSError:
|
|
177
|
+
pass
|
|
178
|
+
return stopped
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def restart(stand: Stand, *, run_dir: Optional[Path] = None, log_dir: Optional[Path] = None) -> int:
|
|
182
|
+
"""Останавливает (если жив) и заново запускает стенд. Возвращает новый pid."""
|
|
183
|
+
stop(stand, run_dir=run_dir)
|
|
184
|
+
return start(stand, run_dir=run_dir, log_dir=log_dir)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def is_running(stand: Stand, *, run_dir: Optional[Path] = None) -> bool:
|
|
188
|
+
"""Быстрая проверка «жив ли стенд» по pidfile (обёртка над standkit.health.process_alive)."""
|
|
189
|
+
_require_local(stand)
|
|
190
|
+
pid = _read_pid(pidfile_path(stand, run_dir))
|
|
191
|
+
return pid is not None and _platform.is_alive(pid)
|
standkit/logs.py
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Работа с per-stand лог-файлом: tail последних N строк и генератор "follow"
|
|
3
|
+
(аналог ``tail -f``) для стрим-панели GUI/агента.
|
|
4
|
+
|
|
5
|
+
Читает файл КАК БАЙТЫ и декодирует "умным" перебором кодировок (см.
|
|
6
|
+
``_decode_bytes``), а не строго как UTF-8: дочерний .NET-процесс стенда на
|
|
7
|
+
Windows нередко пишет консольный вывод в системной однобайтовой кодировке
|
|
8
|
+
(cp1251/cp866), а не в UTF-8 — строгий "utf-8"-декод в этом случае бил
|
|
9
|
+
кириллицу в "◇" вместо того, чтобы показать её как есть.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import time
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Iterator, Optional
|
|
17
|
+
|
|
18
|
+
# Однобайтовые кандидаты для скоринга (см. ``_decode_bytes``). UTF-8/UTF-8-BOM
|
|
19
|
+
# сюда не входят — они проверяются отдельно, СТРОГО, до скоринга (см. ниже).
|
|
20
|
+
_SINGLE_BYTE_CANDIDATES = ("cp1251", "cp866")
|
|
21
|
+
|
|
22
|
+
# --- скоринг "читаемости" декодированного текста ---
|
|
23
|
+
#
|
|
24
|
+
# cp1251 и cp866 — обе однобайтовые кодировки: почти ЛЮБАЯ байтовая
|
|
25
|
+
# последовательность декодируется ими БЕЗ ОШИБОК (за редкими исключениями
|
|
26
|
+
# вроде байта 0x98 в cp1251), поэтому раньше "первая, что декодировалась без
|
|
27
|
+
# ошибок" почти всегда отдавала предпочтение cp1251 (она была в списке
|
|
28
|
+
# кандидатов раньше cp866) — даже когда файл был на самом деле в cp866, что
|
|
29
|
+
# давало мойибаке на кириллице. Вместо "первая успешная" — декодируем ОБЕИМИ
|
|
30
|
+
# кандидатными кодировками (``errors="replace"``, чтобы не падать) и выбираем
|
|
31
|
+
# ту, чей результат "читаемее" по эвристической оценке:
|
|
32
|
+
# + печатные ASCII, \r\n\t, кириллица (U+0400-04FF), типичная пунктуация
|
|
33
|
+
# (кавычки-ёлочки, тире, обычные знаки препинания) — увеличивают оценку;
|
|
34
|
+
# - символ-заменитель (U+FFFD, недекодируемый байт), control-символы C1
|
|
35
|
+
# (U+0080-009F), псевдографика box-drawing (U+2500-257F) и прочие редкие
|
|
36
|
+
# latin-1-символы (U+00A0-00FF вне кириллицы) — уменьшают оценку (это
|
|
37
|
+
# типичный "мусор", который получается при декоде байт НЕ той однобайтовой
|
|
38
|
+
# кодировкой).
|
|
39
|
+
_GOOD_PUNCTUATION = set("«»—-.,!?;:()[]{}'\"/\\%$#@&*+=_~`")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _readability_score(text: str) -> int:
|
|
43
|
+
"""Эвристическая оценка "читаемости" текста — см. пояснение выше модуля."""
|
|
44
|
+
score = 0
|
|
45
|
+
for ch in text:
|
|
46
|
+
cp = ord(ch)
|
|
47
|
+
if ch in "\r\n\t":
|
|
48
|
+
score += 1
|
|
49
|
+
elif 0x20 <= cp <= 0x7E:
|
|
50
|
+
score += 1
|
|
51
|
+
elif 0x0400 <= cp <= 0x04FF:
|
|
52
|
+
score += 2
|
|
53
|
+
elif ch in _GOOD_PUNCTUATION:
|
|
54
|
+
score += 1
|
|
55
|
+
elif ch == "�":
|
|
56
|
+
score -= 6
|
|
57
|
+
elif 0x80 <= cp <= 0x9F:
|
|
58
|
+
score -= 6
|
|
59
|
+
elif 0x2500 <= cp <= 0x257F:
|
|
60
|
+
score -= 5
|
|
61
|
+
elif 0xA0 <= cp <= 0xFF:
|
|
62
|
+
score -= 2
|
|
63
|
+
else:
|
|
64
|
+
score -= 1
|
|
65
|
+
return score
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _decode_single_byte_best(data: bytes) -> str:
|
|
69
|
+
"""
|
|
70
|
+
Декодирует байты каждой однобайтовой кандидатной кодировкой (см.
|
|
71
|
+
``_SINGLE_BYTE_CANDIDATES``, ``errors="replace"`` — эти кодировки
|
|
72
|
+
практически никогда не бросают исключение сами по себе) и возвращает
|
|
73
|
+
результат кодировки с НАИБОЛЬШЕЙ оценкой читаемости (``_readability_score``).
|
|
74
|
+
При равной оценке побеждает первый кандидат по порядку списка (детерминизм).
|
|
75
|
+
"""
|
|
76
|
+
best_text: Optional[str] = None
|
|
77
|
+
best_score: Optional[int] = None
|
|
78
|
+
for encoding in _SINGLE_BYTE_CANDIDATES:
|
|
79
|
+
decoded = data.decode(encoding, errors="replace")
|
|
80
|
+
sc = _readability_score(decoded)
|
|
81
|
+
if best_score is None or sc > best_score:
|
|
82
|
+
best_text, best_score = decoded, sc
|
|
83
|
+
assert best_text is not None # список кандидатов непуст
|
|
84
|
+
return best_text
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _decode_bytes(data: bytes) -> str:
|
|
88
|
+
"""
|
|
89
|
+
Декодирует байты лога:
|
|
90
|
+
|
|
91
|
+
(a) если байты — валидный UTF-8/UTF-8 с BOM (``errors="strict"``) —
|
|
92
|
+
возвращает его как есть (UTF-8 в приоритете, если он строго валиден:
|
|
93
|
+
однобайтовые кодировки почти никогда не бракуют произвольные байты,
|
|
94
|
+
поэтому им нельзя доверять раньше строгой UTF-8-проверки);
|
|
95
|
+
(b) иначе — скоринг читаемости между cp1251 и cp866 (см.
|
|
96
|
+
``_decode_single_byte_best``), кодировка с большей оценкой побеждает;
|
|
97
|
+
(c) если это тоже не помогло (не должно происходить — (b) всегда
|
|
98
|
+
возвращает строку) — фолбэк на UTF-8 с ``errors="replace"``, чтобы
|
|
99
|
+
никогда не падать на бинарном мусоре.
|
|
100
|
+
"""
|
|
101
|
+
for encoding in ("utf-8-sig", "utf-8"):
|
|
102
|
+
try:
|
|
103
|
+
return data.decode(encoding, errors="strict")
|
|
104
|
+
except UnicodeDecodeError:
|
|
105
|
+
continue
|
|
106
|
+
try:
|
|
107
|
+
return _decode_single_byte_best(data)
|
|
108
|
+
except Exception:
|
|
109
|
+
return data.decode("utf-8", errors="replace")
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def tail(log_path: Path, n: int = 100) -> list[str]:
|
|
113
|
+
"""
|
|
114
|
+
Возвращает последние ``n`` строк файла лога (без завершающих переводов строк).
|
|
115
|
+
|
|
116
|
+
Если файла ещё нет (стенд ни разу не запускался) — возвращает пустой список,
|
|
117
|
+
а не бросает исключение: отсутствие лога — обычное состояние свежей записи
|
|
118
|
+
реестра.
|
|
119
|
+
|
|
120
|
+
Реализация читает файл целиком (байтами, с "умным" декодом — см.
|
|
121
|
+
``_decode_bytes``) — для типичных per-stand логов (десятки МБ) этого
|
|
122
|
+
достаточно; TODO(следующая итерация): постраничное чтение с конца файла
|
|
123
|
+
для очень больших логов (сотни МБ и больше), чтобы не держать в памяти
|
|
124
|
+
файл целиком.
|
|
125
|
+
"""
|
|
126
|
+
p = Path(log_path)
|
|
127
|
+
if not p.exists():
|
|
128
|
+
return []
|
|
129
|
+
data = p.read_bytes()
|
|
130
|
+
text = _decode_bytes(data)
|
|
131
|
+
lines = text.splitlines()
|
|
132
|
+
return lines[-n:] if n > 0 else []
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
_SESSION_START_MARKER = "=== START pid="
|
|
136
|
+
_APP_STARTING_MARKER = "Application starting"
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def extract_current_session(text: str) -> str:
|
|
140
|
+
"""
|
|
141
|
+
Обрезает текст лога до ТЕКУЩЕЙ (последней) сессии стенда — панель
|
|
142
|
+
"Текущее состояние" в хабе не должна показывать хвост со ВСЕМИ прошлыми
|
|
143
|
+
запусками (десятки блоков "=== START pid=…"/"Application starting…"), а
|
|
144
|
+
только вывод с начала последнего запуска.
|
|
145
|
+
|
|
146
|
+
Границы сессии ищутся с КОНЦА текста (побеждает последнее вхождение), в
|
|
147
|
+
порядке приоритета:
|
|
148
|
+
1. строка, содержащая ``"=== START pid="`` — маркер, который standkit
|
|
149
|
+
пишет в лог при старте процесса стенда (формат
|
|
150
|
+
``=== START pid=NNNN ts=<ISO> ===``);
|
|
151
|
+
2. если такой строки нет — строка, содержащая ``"Application starting"``
|
|
152
|
+
(типичная первая строка вывода .NET-хоста стенда при холодном
|
|
153
|
+
старте — используется как fallback для логов, которые ведёт не сам
|
|
154
|
+
standkit, а сам стенд/сторонний раннер, без маркера ``START pid=``).
|
|
155
|
+
|
|
156
|
+
Если ни одна граница не найдена — возвращает текст без изменений (лог
|
|
157
|
+
короткий/не содержит распознаваемых маркеров, обрезать нечего).
|
|
158
|
+
|
|
159
|
+
Найденная граничная строка ВКЛЮЧАЕТСЯ в результат (сессия показывается
|
|
160
|
+
с "=== START pid=…" или "Application starting…" включительно).
|
|
161
|
+
"""
|
|
162
|
+
if not text:
|
|
163
|
+
return text
|
|
164
|
+
|
|
165
|
+
lines = text.split("\n")
|
|
166
|
+
|
|
167
|
+
start_idx: Optional[int] = None
|
|
168
|
+
for i in range(len(lines) - 1, -1, -1):
|
|
169
|
+
if _SESSION_START_MARKER in lines[i]:
|
|
170
|
+
start_idx = i
|
|
171
|
+
break
|
|
172
|
+
|
|
173
|
+
if start_idx is None:
|
|
174
|
+
for i in range(len(lines) - 1, -1, -1):
|
|
175
|
+
if _APP_STARTING_MARKER in lines[i]:
|
|
176
|
+
start_idx = i
|
|
177
|
+
break
|
|
178
|
+
|
|
179
|
+
if start_idx is None:
|
|
180
|
+
return text
|
|
181
|
+
|
|
182
|
+
return "\n".join(lines[start_idx:])
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def follow(log_path: Path, *, poll_interval: float = 0.5) -> Iterator[str]:
|
|
186
|
+
"""
|
|
187
|
+
Генератор, отдающий новые строки лога по мере их появления (аналог ``tail -f``).
|
|
188
|
+
|
|
189
|
+
Блокирующий: между появлениями новых строк "спит" ``poll_interval`` секунд.
|
|
190
|
+
Предназначен для использования в отдельном потоке/соединении (например,
|
|
191
|
+
long-poll или SSE-эндпоинт агента) — вызывающая сторона сама решает, когда
|
|
192
|
+
остановить итерацию (просто перестать тянуть значения из генератора).
|
|
193
|
+
|
|
194
|
+
Читает файл в байтовом режиме и декодирует каждую завершённую строку через
|
|
195
|
+
``_decode_bytes`` — тот же "умный" перебор кодировок, что и в ``tail``,
|
|
196
|
+
построчно (буферизуя неполный "хвост" до следующего перевода строки),
|
|
197
|
+
чтобы не резать многобайтовые последовательности пополам между двумя
|
|
198
|
+
опросами.
|
|
199
|
+
|
|
200
|
+
TODO(следующая итерация): обработка ротации лог-файла (например, через
|
|
201
|
+
logrotate) — сейчас при пересоздании файла с тем же именем позиция чтения
|
|
202
|
+
может "уехать" за пределы нового файла и follow() перестанет отдавать строки
|
|
203
|
+
до следующего перезапуска процесса-читателя.
|
|
204
|
+
"""
|
|
205
|
+
p = Path(log_path)
|
|
206
|
+
# Ждём появления файла, если стенд ещё не успел его создать.
|
|
207
|
+
while not p.exists():
|
|
208
|
+
time.sleep(poll_interval)
|
|
209
|
+
|
|
210
|
+
with p.open("rb") as f:
|
|
211
|
+
f.seek(0, 2) # сразу к концу файла — follow не показывает историю
|
|
212
|
+
buffer = b""
|
|
213
|
+
while True:
|
|
214
|
+
chunk = f.read()
|
|
215
|
+
if chunk:
|
|
216
|
+
buffer += chunk
|
|
217
|
+
while b"\n" in buffer:
|
|
218
|
+
raw_line, buffer = buffer.split(b"\n", 1)
|
|
219
|
+
yield _decode_bytes(raw_line).rstrip("\r")
|
|
220
|
+
else:
|
|
221
|
+
time.sleep(poll_interval)
|