standkit 0.5.1__tar.gz → 0.5.2__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.
- {standkit-0.5.1/standkit.egg-info → standkit-0.5.2}/PKG-INFO +2 -3
- {standkit-0.5.1 → standkit-0.5.2}/docs/HOSTING.md +28 -10
- {standkit-0.5.1 → standkit-0.5.2}/pyproject.toml +4 -4
- {standkit-0.5.1 → standkit-0.5.2}/standkit/__init__.py +1 -1
- {standkit-0.5.1 → standkit-0.5.2}/standkit/hosting.py +79 -16
- {standkit-0.5.1 → standkit-0.5.2}/standkit/logs.py +20 -5
- {standkit-0.5.1 → standkit-0.5.2/standkit.egg-info}/PKG-INFO +2 -3
- {standkit-0.5.1 → standkit-0.5.2}/standkit_hub/logs_browser.py +20 -4
- {standkit-0.5.1 → standkit-0.5.2}/standkit_hub/server.py +12 -2
- {standkit-0.5.1 → standkit-0.5.2}/standkit_hub/web/app.js +21 -1
- {standkit-0.5.1 → standkit-0.5.2}/standkit_hub/web/style.css +1 -0
- {standkit-0.5.1 → standkit-0.5.2}/tests/test_hosting.py +33 -0
- {standkit-0.5.1 → standkit-0.5.2}/tests/test_hub_logs_browser.py +33 -0
- {standkit-0.5.1 → standkit-0.5.2}/tests/test_logs.py +15 -0
- {standkit-0.5.1 → standkit-0.5.2}/LICENSE +0 -0
- {standkit-0.5.1 → standkit-0.5.2}/MANIFEST.in +0 -0
- {standkit-0.5.1 → standkit-0.5.2}/README.md +0 -0
- {standkit-0.5.1 → standkit-0.5.2}/SECURITY.md +0 -0
- {standkit-0.5.1 → standkit-0.5.2}/docs/ARCHITECTURE.md +0 -0
- {standkit-0.5.1 → standkit-0.5.2}/docs/REMOTE_STANDS.md +0 -0
- {standkit-0.5.1 → standkit-0.5.2}/docs/adr/0001-hosting-backends.md +0 -0
- {standkit-0.5.1 → standkit-0.5.2}/docs/adr/0002-k8s-backend.md +0 -0
- {standkit-0.5.1 → standkit-0.5.2}/projects.sample.json +0 -0
- {standkit-0.5.1 → standkit-0.5.2}/setup.cfg +0 -0
- {standkit-0.5.1 → standkit-0.5.2}/standkit/health.py +0 -0
- {standkit-0.5.1 → standkit-0.5.2}/standkit/lifecycle.py +0 -0
- {standkit-0.5.1 → standkit-0.5.2}/standkit/models.py +0 -0
- {standkit-0.5.1 → standkit-0.5.2}/standkit/platform.py +0 -0
- {standkit-0.5.1 → standkit-0.5.2}/standkit/registry.py +0 -0
- {standkit-0.5.1 → standkit-0.5.2}/standkit/secrets.py +0 -0
- {standkit-0.5.1 → standkit-0.5.2}/standkit.egg-info/SOURCES.txt +0 -0
- {standkit-0.5.1 → standkit-0.5.2}/standkit.egg-info/dependency_links.txt +0 -0
- {standkit-0.5.1 → standkit-0.5.2}/standkit.egg-info/entry_points.txt +0 -0
- {standkit-0.5.1 → standkit-0.5.2}/standkit.egg-info/requires.txt +0 -0
- {standkit-0.5.1 → standkit-0.5.2}/standkit.egg-info/top_level.txt +0 -0
- {standkit-0.5.1 → standkit-0.5.2}/standkit_agent/__init__.py +0 -0
- {standkit-0.5.1 → standkit-0.5.2}/standkit_agent/__main__.py +0 -0
- {standkit-0.5.1 → standkit-0.5.2}/standkit_agent/audit.py +0 -0
- {standkit-0.5.1 → standkit-0.5.2}/standkit_agent/security.py +0 -0
- {standkit-0.5.1 → standkit-0.5.2}/standkit_agent/server.py +0 -0
- {standkit-0.5.1 → standkit-0.5.2}/standkit_hub/__init__.py +0 -0
- {standkit-0.5.1 → standkit-0.5.2}/standkit_hub/__main__.py +0 -0
- {standkit-0.5.1 → standkit-0.5.2}/standkit_hub/agent_control.py +0 -0
- {standkit-0.5.1 → standkit-0.5.2}/standkit_hub/assets/bpmkit-icon.ico +0 -0
- {standkit-0.5.1 → standkit-0.5.2}/standkit_hub/assets/icon.png +0 -0
- {standkit-0.5.1 → standkit-0.5.2}/standkit_hub/client.py +0 -0
- {standkit-0.5.1 → standkit-0.5.2}/standkit_hub/config.py +0 -0
- {standkit-0.5.1 → standkit-0.5.2}/standkit_hub/redis_min.py +0 -0
- {standkit-0.5.1 → standkit-0.5.2}/standkit_hub/security.py +0 -0
- {standkit-0.5.1 → standkit-0.5.2}/standkit_hub/shortcut.py +0 -0
- {standkit-0.5.1 → standkit-0.5.2}/standkit_hub/web/bpmkit-logo-dark.svg +0 -0
- {standkit-0.5.1 → standkit-0.5.2}/standkit_hub/web/bpmkit-logo.svg +0 -0
- {standkit-0.5.1 → standkit-0.5.2}/standkit_hub/web/favicon.png +0 -0
- {standkit-0.5.1 → standkit-0.5.2}/standkit_hub/web/favicon.svg +0 -0
- {standkit-0.5.1 → standkit-0.5.2}/standkit_hub/web/index.html +0 -0
- {standkit-0.5.1 → standkit-0.5.2}/tests/test_agent_security.py +0 -0
- {standkit-0.5.1 → standkit-0.5.2}/tests/test_agent_server_integration.py +0 -0
- {standkit-0.5.1 → standkit-0.5.2}/tests/test_health.py +0 -0
- {standkit-0.5.1 → standkit-0.5.2}/tests/test_hub_agent_control.py +0 -0
- {standkit-0.5.1 → standkit-0.5.2}/tests/test_hub_config.py +0 -0
- {standkit-0.5.1 → standkit-0.5.2}/tests/test_hub_redis_min.py +0 -0
- {standkit-0.5.1 → standkit-0.5.2}/tests/test_hub_register.py +0 -0
- {standkit-0.5.1 → standkit-0.5.2}/tests/test_hub_server.py +0 -0
- {standkit-0.5.1 → standkit-0.5.2}/tests/test_hub_shortcut.py +0 -0
- {standkit-0.5.1 → standkit-0.5.2}/tests/test_lifecycle.py +0 -0
- {standkit-0.5.1 → standkit-0.5.2}/tests/test_models.py +0 -0
- {standkit-0.5.1 → standkit-0.5.2}/tests/test_registry.py +0 -0
- {standkit-0.5.1 → standkit-0.5.2}/tests/test_registry_resolver.py +0 -0
- {standkit-0.5.1 → standkit-0.5.2}/tests/test_secrets.py +0 -0
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: standkit
|
|
3
|
-
Version: 0.5.
|
|
3
|
+
Version: 0.5.2
|
|
4
4
|
Summary: Свободное ядро (MIT) для управления жизненным циклом локальных и удалённых стендов BPMSoft: headless start/stop/restart, health-пробы, логи, реестр стендов. Часть экосистемы BPMkit.
|
|
5
5
|
Author: standkit contributors
|
|
6
|
-
License: MIT
|
|
6
|
+
License-Expression: MIT
|
|
7
7
|
Project-URL: Homepage, https://github.com/thinkquattro/BPMkitStand
|
|
8
8
|
Project-URL: Экосистема BPMkit, https://github.com/thinkquattro/BPMkit-dev
|
|
9
|
-
Classifier: License :: OSI Approved :: MIT License
|
|
10
9
|
Classifier: Programming Language :: Python :: 3
|
|
11
10
|
Classifier: Programming Language :: Python :: 3.10
|
|
12
11
|
Classifier: Operating System :: OS Independent
|
|
@@ -113,12 +113,23 @@
|
|
|
113
113
|
- **kestrel** — без изменений: `dotnet <stand_dll>` + pidfile
|
|
114
114
|
(`standkit.lifecycle`/`standkit.platform`).
|
|
115
115
|
- **iis** — через `appcmd.exe`
|
|
116
|
-
(`%windir%\system32\inetsrv\appcmd.exe`)
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
116
|
+
(`%windir%\system32\inetsrv\appcmd.exe`). ⚠️ **Требует запуска диспетчера
|
|
117
|
+
«от имени администратора»** — см. раздел «Требования».
|
|
118
|
+
«Стенд в IIS» = его **Site**: диспетчер управляет сайтом и намеренно **не
|
|
119
|
+
трогает App Pool** (пул может быть общим с другими приложениями, его остановка
|
|
120
|
+
положила бы и их). App Pool задействуется только когда `iis_site` не задан
|
|
121
|
+
вовсе (единственный хэндл стенда).
|
|
122
|
+
- старт: `start apppool /apppool.name:<pool>` (если задан, чтобы сайт мог
|
|
123
|
+
обслуживаться) + `start site /site.name:<site>`;
|
|
124
|
+
- стоп: `stop site /site.name:<site>` (**только сайт**; при отсутствии
|
|
125
|
+
`iis_site` — `stop apppool`);
|
|
126
|
+
- рестарт: `stop site` + `start site` (**App Pool не рециклится**; recycle
|
|
127
|
+
пула — только когда сайт не задан);
|
|
128
|
+
- проверка «жив»: состояние **сайта** `list site <site> /text:state` →
|
|
129
|
+
`Started` (при отсутствии `iis_site` — состояние пула); фолбэк на открытый
|
|
130
|
+
TCP-порт — только если `appcmd` не дал определённого состояния (порт держит
|
|
131
|
+
http.sys даже у остановленного сайта, поэтому открытый порт сам по себе
|
|
132
|
+
«живым» не считается).
|
|
122
133
|
- **docker** — через CLI `docker` / `docker compose`:
|
|
123
134
|
- одиночный контейнер: `docker start|stop|restart <container>`,
|
|
124
135
|
`docker inspect -f "{{.State.Running}}" <container>`,
|
|
@@ -144,10 +155,17 @@ Health-проба «процесс» (`standkit.health.check_stand`) для `iis
|
|
|
144
155
|
|
|
145
156
|
## Требования
|
|
146
157
|
|
|
147
|
-
- **IIS**: платформа — только Windows
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
158
|
+
- **IIS**: платформа — только Windows. ⚠️ **Диспетчер (`standkit-hub`) должен
|
|
159
|
+
быть запущен «от имени администратора» (elevated).** `appcmd.exe` управляет
|
|
160
|
+
конфигурацией IIS и читает `%windir%\system32\inetsrv\config\redirection.config`,
|
|
161
|
+
что требует прав администратора; без elevation любая IIS-операция падает с
|
|
162
|
+
ошибкой прав (например, `код 1168` / «не удалось открыть файл конфигурации
|
|
163
|
+
из-за отсутствия необходимых разрешений»). Членства в группе `IIS_IUSRS`
|
|
164
|
+
**недостаточно**. Как запускать elevated: ярлык/консоль → правый клик →
|
|
165
|
+
«Запуск от имени администратора», либо из уже поднятой административной
|
|
166
|
+
консоли `standkit-hub`. Диспетчер в такой ситуации отдаёт понятную подсказку
|
|
167
|
+
«запустите от имени администратора» в тексте ошибки. Живая приёмка — на
|
|
168
|
+
стенде с реальным IIS.
|
|
151
169
|
- **Docker**: установленный Docker Engine (`docker` в PATH процесса
|
|
152
170
|
standkit); для compose-режима — плагин `docker compose` (Compose V2).
|
|
153
171
|
- **Kubernetes**: установленный `kubectl` в PATH процесса standkit и
|
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
[build-system]
|
|
2
|
-
requires = ["setuptools>=
|
|
2
|
+
requires = ["setuptools>=77"]
|
|
3
3
|
build-backend = "setuptools.build_meta"
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "standkit"
|
|
7
|
-
version = "0.5.
|
|
7
|
+
version = "0.5.2"
|
|
8
8
|
description = "Свободное ядро (MIT) для управления жизненным циклом локальных и удалённых стендов BPMSoft: headless start/stop/restart, health-пробы, логи, реестр стендов. Часть экосистемы BPMkit."
|
|
9
9
|
readme = "README.md"
|
|
10
|
-
license =
|
|
10
|
+
license = "MIT"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
11
12
|
authors = [{ name = "standkit contributors" }]
|
|
12
13
|
requires-python = ">=3.10"
|
|
13
14
|
classifiers = [
|
|
14
|
-
"License :: OSI Approved :: MIT License",
|
|
15
15
|
"Programming Language :: Python :: 3",
|
|
16
16
|
"Programming Language :: Python :: 3.10",
|
|
17
17
|
"Operating System :: OS Independent",
|
|
@@ -83,19 +83,66 @@ def get_backend(stand: Stand) -> HostingBackend:
|
|
|
83
83
|
raise HostingError(f"host_kind={stand.host_kind.value!r} не поддерживается ядром standkit")
|
|
84
84
|
|
|
85
85
|
|
|
86
|
+
def _oem_encoding() -> str:
|
|
87
|
+
"""Кодировка вывода консольных утилит Windows (appcmd и т.п. пишут в OEM,
|
|
88
|
+
не в UTF-8/ANSI). Вне Windows — utf-8."""
|
|
89
|
+
if sys.platform == "win32":
|
|
90
|
+
try:
|
|
91
|
+
import ctypes
|
|
92
|
+
|
|
93
|
+
return f"cp{ctypes.windll.kernel32.GetOEMCP()}" # обычно cp866 (RU)
|
|
94
|
+
except Exception:
|
|
95
|
+
return "cp866"
|
|
96
|
+
return "utf-8"
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _decode_console(data) -> str:
|
|
100
|
+
"""Декодирует вывод внешней команды. Принимает bytes (реальный запуск) или
|
|
101
|
+
str (замоканный в тестах — отдаётся как есть). Для bytes перебирает
|
|
102
|
+
utf-8 → OEM (cp866) → cp1251, чтобы не превращать кириллицу appcmd в
|
|
103
|
+
кракозябры (баг «остановка IIS: непонятный текст ошибки»)."""
|
|
104
|
+
if data is None:
|
|
105
|
+
return ""
|
|
106
|
+
if isinstance(data, str):
|
|
107
|
+
return data
|
|
108
|
+
for enc in ("utf-8", _oem_encoding(), "cp1251"):
|
|
109
|
+
try:
|
|
110
|
+
return data.decode(enc)
|
|
111
|
+
except (UnicodeDecodeError, LookupError):
|
|
112
|
+
continue
|
|
113
|
+
return data.decode("utf-8", errors="replace")
|
|
114
|
+
|
|
115
|
+
|
|
86
116
|
def _run(cmd: list[str], *, timeout: float = _DEFAULT_TIMEOUT) -> subprocess.CompletedProcess:
|
|
87
|
-
"""Выполняет внешнюю команду, оборачивая ошибки спавна/таймаута в ``HostingError``.
|
|
117
|
+
"""Выполняет внешнюю команду, оборачивая ошибки спавна/таймаута в ``HostingError``.
|
|
118
|
+
Захватывает вывод БАЙТАМИ и декодирует (см. ``_decode_console``) — надёжнее,
|
|
119
|
+
чем ``text=True, encoding='utf-8'``, для консольных утилит в OEM-кодировке."""
|
|
88
120
|
try:
|
|
89
|
-
|
|
90
|
-
cmd,
|
|
91
|
-
capture_output=True,
|
|
92
|
-
text=True,
|
|
93
|
-
encoding="utf-8",
|
|
94
|
-
errors="replace",
|
|
95
|
-
timeout=timeout,
|
|
96
|
-
)
|
|
121
|
+
proc = subprocess.run(cmd, capture_output=True, timeout=timeout)
|
|
97
122
|
except (OSError, subprocess.SubprocessError) as exc:
|
|
98
123
|
raise HostingError(f"Не удалось выполнить команду {cmd!r}: {exc}") from exc
|
|
124
|
+
return subprocess.CompletedProcess(
|
|
125
|
+
cmd, proc.returncode, _decode_console(proc.stdout), _decode_console(proc.stderr)
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
# Признаки того, что внешняя команда упала из-за НЕХВАТКИ ПРАВ (нужен запуск
|
|
130
|
+
# «от имени администратора»). Для appcmd это типично: чтение config/
|
|
131
|
+
# redirection.config в %windir%\system32\inetsrv требует elevation.
|
|
132
|
+
_ELEVATION_MARKERS = (
|
|
133
|
+
"redirection.config",
|
|
134
|
+
"access is denied",
|
|
135
|
+
"отказано в доступе",
|
|
136
|
+
"разрешени", # «…необходимых разрешений»
|
|
137
|
+
"0x80070005",
|
|
138
|
+
"(код 1168)",
|
|
139
|
+
"error ( message:", # общий appcmd-ERROR при неудачном открытии config
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _looks_like_elevation_error(text: str) -> bool:
|
|
144
|
+
low = text.lower()
|
|
145
|
+
return any(m in low for m in _ELEVATION_MARKERS)
|
|
99
146
|
|
|
100
147
|
|
|
101
148
|
def _run_checked(cmd: list[str], *, timeout: float = _DEFAULT_TIMEOUT) -> subprocess.CompletedProcess:
|
|
@@ -107,6 +154,22 @@ def _run_checked(cmd: list[str], *, timeout: float = _DEFAULT_TIMEOUT) -> subpro
|
|
|
107
154
|
return result
|
|
108
155
|
|
|
109
156
|
|
|
157
|
+
def _appcmd_checked(cmd: list[str], *, timeout: float = _DEFAULT_TIMEOUT) -> subprocess.CompletedProcess:
|
|
158
|
+
"""``_run_checked`` для appcmd: при ошибке нехватки прав добавляет к тексту
|
|
159
|
+
понятную IIS-подсказку «запустите от имени администратора»."""
|
|
160
|
+
try:
|
|
161
|
+
return _run_checked(cmd, timeout=timeout)
|
|
162
|
+
except HostingError as exc:
|
|
163
|
+
if _looks_like_elevation_error(str(exc)):
|
|
164
|
+
raise HostingError(
|
|
165
|
+
str(exc)
|
|
166
|
+
+ "\n\nПохоже, не хватает прав администратора: управление IIS через appcmd.exe "
|
|
167
|
+
"требует запуска диспетчера «от имени администратора» (elevated). Запустите "
|
|
168
|
+
"standkit-hub с правами администратора и повторите операцию."
|
|
169
|
+
) from exc
|
|
170
|
+
raise
|
|
171
|
+
|
|
172
|
+
|
|
110
173
|
def _tcp_fallback(stand: Stand) -> bool:
|
|
111
174
|
"""Фолбэк-проба «жив ли стенд» по открытому TCP-порту (см. ADR-0001)."""
|
|
112
175
|
from standkit import health as _health # локальный импорт — избегаем цикла
|
|
@@ -209,9 +272,9 @@ class IisBackend:
|
|
|
209
272
|
f"стенд '{stand.name}': host_kind=iis требует iis_site и/или iis_app_pool"
|
|
210
273
|
)
|
|
211
274
|
if stand.iis_app_pool:
|
|
212
|
-
|
|
275
|
+
_appcmd_checked([appcmd, "start", "apppool", f"/apppool.name:{stand.iis_app_pool}"])
|
|
213
276
|
if stand.iis_site:
|
|
214
|
-
|
|
277
|
+
_appcmd_checked([appcmd, "start", "site", f"/site.name:{stand.iis_site}"])
|
|
215
278
|
return None
|
|
216
279
|
|
|
217
280
|
def stop(self, stand: Stand, *, run_dir: Optional[Path] = None) -> bool:
|
|
@@ -225,9 +288,9 @@ class IisBackend:
|
|
|
225
288
|
# положила бы и их (решение Владимира — гасить только стенд). App Pool
|
|
226
289
|
# гасим лишь как единственный хэндл, когда сайт вообще не задан.
|
|
227
290
|
if stand.iis_site:
|
|
228
|
-
|
|
291
|
+
_appcmd_checked([appcmd, "stop", "site", f"/site.name:{stand.iis_site}"])
|
|
229
292
|
return True
|
|
230
|
-
|
|
293
|
+
_appcmd_checked([appcmd, "stop", "apppool", f"/apppool.name:{stand.iis_app_pool}"])
|
|
231
294
|
return True
|
|
232
295
|
|
|
233
296
|
def restart(
|
|
@@ -238,10 +301,10 @@ class IisBackend:
|
|
|
238
301
|
# трогаем/не рециклим — он может быть общим (см. stop). Recycle пула —
|
|
239
302
|
# только когда сайт не задан (пул — единственный хэндл стенда).
|
|
240
303
|
if stand.iis_site:
|
|
241
|
-
|
|
242
|
-
|
|
304
|
+
_appcmd_checked([appcmd, "stop", "site", f"/site.name:{stand.iis_site}"])
|
|
305
|
+
_appcmd_checked([appcmd, "start", "site", f"/site.name:{stand.iis_site}"])
|
|
243
306
|
elif stand.iis_app_pool:
|
|
244
|
-
|
|
307
|
+
_appcmd_checked([appcmd, "recycle", "apppool", f"/apppool.name:{stand.iis_app_pool}"])
|
|
245
308
|
else:
|
|
246
309
|
raise HostingError(
|
|
247
310
|
f"стенд '{stand.name}': host_kind=iis требует iis_site и/или iis_app_pool"
|
|
@@ -109,7 +109,7 @@ def _decode_bytes(data: bytes) -> str:
|
|
|
109
109
|
return data.decode("utf-8", errors="replace")
|
|
110
110
|
|
|
111
111
|
|
|
112
|
-
def tail(log_path: Path, n: int = 100) -> list[str]:
|
|
112
|
+
def tail(log_path: Path, n: int = 100, *, max_bytes: Optional[int] = None) -> list[str]:
|
|
113
113
|
"""
|
|
114
114
|
Возвращает последние ``n`` строк файла лога (без завершающих переводов строк).
|
|
115
115
|
|
|
@@ -117,14 +117,29 @@ def tail(log_path: Path, n: int = 100) -> list[str]:
|
|
|
117
117
|
а не бросает исключение: отсутствие лога — обычное состояние свежей записи
|
|
118
118
|
реестра.
|
|
119
119
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
120
|
+
``max_bytes`` (опц.) — для ОЧЕНЬ больших логов (IIS/.NET-хосты могут писать
|
|
121
|
+
сотни МБ в день) читать не весь файл, а только последние ``max_bytes`` байт
|
|
122
|
+
(с конца, через seek). Первая строка такого хвоста почти наверняка обрезана
|
|
123
|
+
посередине — она отбрасывается. Без ``max_bytes`` — прежнее поведение (файл
|
|
124
|
+
целиком).
|
|
124
125
|
"""
|
|
125
126
|
p = Path(log_path)
|
|
126
127
|
if not p.exists():
|
|
127
128
|
return []
|
|
129
|
+
if max_bytes is not None and max_bytes > 0:
|
|
130
|
+
try:
|
|
131
|
+
size = p.stat().st_size
|
|
132
|
+
except OSError:
|
|
133
|
+
size = 0
|
|
134
|
+
if size > max_bytes:
|
|
135
|
+
with p.open("rb") as f:
|
|
136
|
+
f.seek(size - max_bytes)
|
|
137
|
+
data = f.read()
|
|
138
|
+
text = _decode_bytes(data)
|
|
139
|
+
lines = text.splitlines()
|
|
140
|
+
if lines:
|
|
141
|
+
lines = lines[1:] # обрезанная «половинка» первой строки
|
|
142
|
+
return lines[-n:] if n > 0 else lines
|
|
128
143
|
data = p.read_bytes()
|
|
129
144
|
text = _decode_bytes(data)
|
|
130
145
|
lines = text.splitlines()
|
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: standkit
|
|
3
|
-
Version: 0.5.
|
|
3
|
+
Version: 0.5.2
|
|
4
4
|
Summary: Свободное ядро (MIT) для управления жизненным циклом локальных и удалённых стендов BPMSoft: headless start/stop/restart, health-пробы, логи, реестр стендов. Часть экосистемы BPMkit.
|
|
5
5
|
Author: standkit contributors
|
|
6
|
-
License: MIT
|
|
6
|
+
License-Expression: MIT
|
|
7
7
|
Project-URL: Homepage, https://github.com/thinkquattro/BPMkitStand
|
|
8
8
|
Project-URL: Экосистема BPMkit, https://github.com/thinkquattro/BPMkit-dev
|
|
9
|
-
Classifier: License :: OSI Approved :: MIT License
|
|
10
9
|
Classifier: Programming Language :: Python :: 3
|
|
11
10
|
Classifier: Programming Language :: Python :: 3.10
|
|
12
11
|
Classifier: Operating System :: OS Independent
|
|
@@ -90,13 +90,26 @@ def resolve_logs_dir(stand: Stand, source: str = DEFAULT_LOG_SOURCE) -> Optional
|
|
|
90
90
|
return p
|
|
91
91
|
|
|
92
92
|
|
|
93
|
-
def
|
|
93
|
+
def start_of_today_ts() -> float:
|
|
94
|
+
"""Unix-timestamp локальной полуночи сегодняшнего дня (для отсечения
|
|
95
|
+
старых логов «старше сегодня» — логи IIS/.NET по дням бывают очень тяжёлыми)."""
|
|
96
|
+
import datetime as _dt
|
|
97
|
+
|
|
98
|
+
now = _dt.datetime.now()
|
|
99
|
+
return now.replace(hour=0, minute=0, second=0, microsecond=0).timestamp()
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def list_log_files(logs_dir: Path, *, since_mtime: Optional[float] = None) -> list[dict]:
|
|
94
103
|
"""
|
|
95
104
|
Список лог-файлов каталога, ВКЛЮЧАЯ вложенные подкаталоги: стенды BPMSoft
|
|
96
105
|
(и .NET-хосты вообще) часто пишут логи не плоско, а в подпапки по датам,
|
|
97
106
|
например ``logs/2026-07-24/*.log``. Без рекурсии верхний уровень содержал
|
|
98
107
|
бы только папки, и хаб ошибочно сообщал «в каталоге нет файлов».
|
|
99
108
|
|
|
109
|
+
``since_mtime`` (опц.) — не включать файлы, изменённые РАНЬШЕ этого unix-ts
|
|
110
|
+
(напр. ``start_of_today_ts()`` — «только за сегодня»): у IIS/.NET накапливаются
|
|
111
|
+
сотни тяжёлых дневных файлов, перечислять/читать всё — дорого.
|
|
112
|
+
|
|
100
113
|
``name`` — путь файла ОТНОСИТЕЛЬНО ``logs_dir`` в POSIX-форме (с прямыми
|
|
101
114
|
слэшами, напр. ``2026-07-24/app.log``), совместимый с
|
|
102
115
|
``sanitize_log_filename`` на любой ОС. Размер в байтах, mtime (unix
|
|
@@ -110,15 +123,18 @@ def list_log_files(logs_dir: Path) -> list[dict]:
|
|
|
110
123
|
st = child.stat()
|
|
111
124
|
except OSError:
|
|
112
125
|
continue
|
|
126
|
+
if since_mtime is not None and st.st_mtime < since_mtime:
|
|
127
|
+
continue
|
|
113
128
|
rel = child.relative_to(logs_dir).as_posix()
|
|
114
129
|
entries.append({"name": rel, "size": st.st_size, "mtime": st.st_mtime})
|
|
115
130
|
entries.sort(key=lambda e: e["mtime"], reverse=True)
|
|
116
131
|
return entries
|
|
117
132
|
|
|
118
133
|
|
|
119
|
-
def pick_primary_log(logs_dir: Path) -> Optional[Path]:
|
|
120
|
-
"""Выбирает "основной" лог каталога — самый свежий по mtime файл.
|
|
121
|
-
|
|
134
|
+
def pick_primary_log(logs_dir: Path, *, since_mtime: Optional[float] = None) -> Optional[Path]:
|
|
135
|
+
"""Выбирает "основной" лог каталога — самый свежий по mtime файл. С
|
|
136
|
+
``since_mtime`` учитывает только файлы не старше указанного момента."""
|
|
137
|
+
files = list_log_files(logs_dir, since_mtime=since_mtime)
|
|
122
138
|
if not files:
|
|
123
139
|
return None
|
|
124
140
|
return logs_dir / files[0]["name"]
|
|
@@ -511,7 +511,15 @@ def make_handler(
|
|
|
511
511
|
},
|
|
512
512
|
)
|
|
513
513
|
return
|
|
514
|
-
|
|
514
|
+
# «Отрезаем всё старше сегодня»: логи IIS/.NET по дням бывают очень
|
|
515
|
+
# тяжёлыми — сначала берём самый свежий файл ЗА СЕГОДНЯ. Если сегодня
|
|
516
|
+
# логов нет (стенд не писал сегодня) — фолбэк на самый свежий вообще,
|
|
517
|
+
# чтобы всегда показать последнюю сессию.
|
|
518
|
+
primary = logs_browser.pick_primary_log(
|
|
519
|
+
logs_dir, since_mtime=logs_browser.start_of_today_ts()
|
|
520
|
+
)
|
|
521
|
+
if primary is None:
|
|
522
|
+
primary = logs_browser.pick_primary_log(logs_dir)
|
|
515
523
|
if primary is None:
|
|
516
524
|
self._send_json(
|
|
517
525
|
200,
|
|
@@ -528,7 +536,9 @@ def make_handler(
|
|
|
528
536
|
# до конца файла), даже если она сама по себе длинная — затем
|
|
529
537
|
# extract_current_session() отрезает всё, что относится к прошлым
|
|
530
538
|
# запускам, и уже результат капается до разумного размера для UI.
|
|
531
|
-
|
|
539
|
+
# Читаем только хвост файла (до 4 МБ) — даже дневной IIS-лог в сотни
|
|
540
|
+
# МБ не грузим целиком в память ради последних строк.
|
|
541
|
+
raw_lines = _logs.tail(primary, 4000, max_bytes=4_000_000)
|
|
532
542
|
raw_text = "\n".join(raw_lines)
|
|
533
543
|
session_text = _logs.extract_current_session(raw_text) if raw_text else ""
|
|
534
544
|
if session_text:
|
|
@@ -553,6 +553,26 @@
|
|
|
553
553
|
}
|
|
554
554
|
}
|
|
555
555
|
|
|
556
|
+
// Ручное обновление по кнопке — с видимой обратной связью: кнопка
|
|
557
|
+
// блокируется и меняет подпись на «Обновление…», по завершении показывается
|
|
558
|
+
// подтверждение со временем (у автообновления по таймеру этого нет, чтобы не
|
|
559
|
+
// мигать постоянно).
|
|
560
|
+
async function refreshStandsWithFeedback() {
|
|
561
|
+
const btn = document.getElementById("refresh-stands-btn");
|
|
562
|
+
const orig = btn.textContent;
|
|
563
|
+
btn.disabled = true;
|
|
564
|
+
btn.textContent = "Обновление…";
|
|
565
|
+
try {
|
|
566
|
+
await refreshStands();
|
|
567
|
+
if (!document.getElementById("stands-error").textContent) {
|
|
568
|
+
showActionStatus(`Список обновлён • ${new Date().toLocaleTimeString()}`, false);
|
|
569
|
+
}
|
|
570
|
+
} finally {
|
|
571
|
+
btn.textContent = orig;
|
|
572
|
+
btn.disabled = false;
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
|
|
556
576
|
function renderStands(stands) {
|
|
557
577
|
const tbody = document.getElementById("stands-tbody");
|
|
558
578
|
tbody.innerHTML = "";
|
|
@@ -952,7 +972,7 @@
|
|
|
952
972
|
setupAgentTab();
|
|
953
973
|
setupSettingsForm();
|
|
954
974
|
setupStatePanel();
|
|
955
|
-
document.getElementById("refresh-stands-btn").addEventListener("click",
|
|
975
|
+
document.getElementById("refresh-stands-btn").addEventListener("click", refreshStandsWithFeedback);
|
|
956
976
|
|
|
957
977
|
refreshStands();
|
|
958
978
|
refreshAgentStatus();
|
|
@@ -40,6 +40,39 @@ def _completed(cmd, returncode=0, stdout="", stderr=""):
|
|
|
40
40
|
return subprocess.CompletedProcess(args=cmd, returncode=returncode, stdout=stdout, stderr=stderr)
|
|
41
41
|
|
|
42
42
|
|
|
43
|
+
# --------------------------------------------------------------------------
|
|
44
|
+
# Декодирование вывода консольных утилит + подсказка о правах администратора
|
|
45
|
+
# --------------------------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_decode_console_handles_str_bytes_and_oem(monkeypatch):
|
|
49
|
+
# str (замоканный вывод в тестах) и None — как есть; bytes декодируются.
|
|
50
|
+
assert hosting._decode_console("Started") == "Started"
|
|
51
|
+
assert hosting._decode_console(None) == ""
|
|
52
|
+
assert hosting._decode_console("Привет".encode("utf-8")) == "Привет"
|
|
53
|
+
# OEM-байты (cp866 у appcmd на RU-Windows) не превращаются в кракозябры.
|
|
54
|
+
monkeypatch.setattr(hosting, "_oem_encoding", lambda: "cp866")
|
|
55
|
+
assert hosting._decode_console("Ошибка".encode("cp866")) == "Ошибка"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_iis_stop_appcmd_permission_error_adds_admin_hint(monkeypatch, tmp_path):
|
|
59
|
+
# appcmd падает из-за нехватки прав (redirection.config) → к тексту ошибки
|
|
60
|
+
# добавляется понятная подсказка «запустите от имени администратора».
|
|
61
|
+
_prep_iis_windows(monkeypatch, tmp_path)
|
|
62
|
+
|
|
63
|
+
def _fake_run(cmd, **kw):
|
|
64
|
+
return _completed(
|
|
65
|
+
cmd,
|
|
66
|
+
returncode=1,
|
|
67
|
+
stderr="ERROR ( message: redirection.config ... необходимых разрешений )",
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
monkeypatch.setattr(subprocess, "run", _fake_run)
|
|
71
|
+
stand = _make_stand(host_kind=HostKind.IIS, iis_site="site1")
|
|
72
|
+
with pytest.raises(HostingError, match="администратор"):
|
|
73
|
+
IisBackend().stop(stand)
|
|
74
|
+
|
|
75
|
+
|
|
43
76
|
# --------------------------------------------------------------------------
|
|
44
77
|
# get_backend
|
|
45
78
|
# --------------------------------------------------------------------------
|
|
@@ -266,3 +266,36 @@ def test_open_folder_uses_os_startfile_on_windows(tmp_path, monkeypatch):
|
|
|
266
266
|
result = open_folder(tmp_path)
|
|
267
267
|
assert result.ok is True
|
|
268
268
|
assert calls["path"] == str(tmp_path)
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def test_list_log_files_since_mtime_filters_older(tmp_path):
|
|
272
|
+
import os
|
|
273
|
+
import time
|
|
274
|
+
|
|
275
|
+
old = tmp_path / "old.log"
|
|
276
|
+
old.write_text("o", encoding="utf-8")
|
|
277
|
+
new = tmp_path / "new.log"
|
|
278
|
+
new.write_text("n", encoding="utf-8")
|
|
279
|
+
day_ago = time.time() - 86400
|
|
280
|
+
os.utime(old, (day_ago, day_ago))
|
|
281
|
+
|
|
282
|
+
cutoff = time.time() - 3600 # час назад
|
|
283
|
+
assert [f["name"] for f in list_log_files(tmp_path, since_mtime=cutoff)] == ["new.log"]
|
|
284
|
+
# без cutoff — оба
|
|
285
|
+
assert {f["name"] for f in list_log_files(tmp_path)} == {"old.log", "new.log"}
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def test_pick_primary_log_since_mtime_ignores_old(tmp_path):
|
|
289
|
+
import os
|
|
290
|
+
import time
|
|
291
|
+
|
|
292
|
+
day = tmp_path / "2026-01-01"
|
|
293
|
+
day.mkdir()
|
|
294
|
+
old = day / "a.log"
|
|
295
|
+
old.write_text("o", encoding="utf-8")
|
|
296
|
+
day_ago = time.time() - 86400
|
|
297
|
+
os.utime(old, (day_ago, day_ago))
|
|
298
|
+
|
|
299
|
+
# только старый файл: с cutoff «за сегодня» → None; без cutoff → он.
|
|
300
|
+
assert pick_primary_log(tmp_path, since_mtime=time.time() - 3600) is None
|
|
301
|
+
assert pick_primary_log(tmp_path) == old
|
|
@@ -203,3 +203,18 @@ def test_extract_current_session_start_marker_wins_over_application_starting():
|
|
|
203
203
|
result = extract_current_session(text)
|
|
204
204
|
assert result.startswith("=== START pid=555")
|
|
205
205
|
assert "старый запуск без маркера START" not in result
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def test_tail_max_bytes_reads_only_end_and_drops_partial_first_line(tmp_path):
|
|
209
|
+
# Огромный лог (IIS/.NET) не читаем целиком: с max_bytes берём только хвост,
|
|
210
|
+
# обрезанную «половинку» первой строки выбрасываем.
|
|
211
|
+
from standkit.logs import tail
|
|
212
|
+
|
|
213
|
+
p = tmp_path / "big.log"
|
|
214
|
+
lines = [f"line{i:03d}" for i in range(100)]
|
|
215
|
+
p.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
216
|
+
size = p.stat().st_size
|
|
217
|
+
|
|
218
|
+
assert tail(p, n=5, max_bytes=size // 2) == lines[-5:]
|
|
219
|
+
assert tail(p, n=3) == lines[-3:] # без max_bytes — как раньше
|
|
220
|
+
assert tail(p, n=2, max_bytes=size * 2) == lines[-2:] # лимит больше файла
|
|
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
|
|
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
|