standkit 0.6.0__tar.gz → 0.6.1__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.
Files changed (81) hide show
  1. {standkit-0.6.0 → standkit-0.6.1}/LICENSE +21 -21
  2. {standkit-0.6.0/standkit.egg-info → standkit-0.6.1}/PKG-INFO +1 -1
  3. {standkit-0.6.0 → standkit-0.6.1}/docs/BACKLOG.md +2 -0
  4. {standkit-0.6.0 → standkit-0.6.1}/docs/CHANGELOG.md +22 -0
  5. {standkit-0.6.0 → standkit-0.6.1}/pyproject.toml +1 -1
  6. {standkit-0.6.0 → standkit-0.6.1}/standkit/__init__.py +1 -1
  7. {standkit-0.6.0 → standkit-0.6.1/standkit.egg-info}/PKG-INFO +1 -1
  8. {standkit-0.6.0 → standkit-0.6.1}/standkit.egg-info/SOURCES.txt +1 -0
  9. {standkit-0.6.0 → standkit-0.6.1}/standkit_agent/__init__.py +9 -9
  10. {standkit-0.6.0 → standkit-0.6.1}/standkit_agent/audit.py +93 -93
  11. {standkit-0.6.0 → standkit-0.6.1}/standkit_hub/__main__.py +21 -2
  12. {standkit-0.6.0 → standkit-0.6.1}/standkit_hub/server.py +105 -0
  13. {standkit-0.6.0 → standkit-0.6.1}/standkit_hub/web/app.js +67 -21
  14. {standkit-0.6.0 → standkit-0.6.1}/standkit_hub/web/index.html +9 -0
  15. {standkit-0.6.0 → standkit-0.6.1}/standkit_hub/web/style.css +25 -0
  16. {standkit-0.6.0 → standkit-0.6.1}/tests/test_agent_security.py +303 -303
  17. {standkit-0.6.0 → standkit-0.6.1}/tests/test_agent_server_integration.py +192 -192
  18. standkit-0.6.1/tests/test_hub_single_instance.py +200 -0
  19. {standkit-0.6.0 → standkit-0.6.1}/MANIFEST.in +0 -0
  20. {standkit-0.6.0 → standkit-0.6.1}/README.md +0 -0
  21. {standkit-0.6.0 → standkit-0.6.1}/SECURITY.md +0 -0
  22. {standkit-0.6.0 → standkit-0.6.1}/docs/ARCHITECTURE.md +0 -0
  23. {standkit-0.6.0 → standkit-0.6.1}/docs/HOSTING.md +0 -0
  24. {standkit-0.6.0 → standkit-0.6.1}/docs/REMOTE_STANDS.md +0 -0
  25. {standkit-0.6.0 → standkit-0.6.1}/docs/ROADMAP.md +0 -0
  26. {standkit-0.6.0 → standkit-0.6.1}/docs/adr/0001-hosting-backends.md +0 -0
  27. {standkit-0.6.0 → standkit-0.6.1}/docs/adr/0002-k8s-backend.md +0 -0
  28. {standkit-0.6.0 → standkit-0.6.1}/docs/adr/0003-remote-log-browsing.md +0 -0
  29. {standkit-0.6.0 → standkit-0.6.1}/docs/adr/0004-iis-site-scoped-lifecycle.md +0 -0
  30. {standkit-0.6.0 → standkit-0.6.1}/projects.sample.json +0 -0
  31. {standkit-0.6.0 → standkit-0.6.1}/setup.cfg +0 -0
  32. {standkit-0.6.0 → standkit-0.6.1}/standkit/adopt.py +0 -0
  33. {standkit-0.6.0 → standkit-0.6.1}/standkit/health.py +0 -0
  34. {standkit-0.6.0 → standkit-0.6.1}/standkit/hosting.py +0 -0
  35. {standkit-0.6.0 → standkit-0.6.1}/standkit/lifecycle.py +0 -0
  36. {standkit-0.6.0 → standkit-0.6.1}/standkit/logs.py +0 -0
  37. {standkit-0.6.0 → standkit-0.6.1}/standkit/models.py +0 -0
  38. {standkit-0.6.0 → standkit-0.6.1}/standkit/platform.py +0 -0
  39. {standkit-0.6.0 → standkit-0.6.1}/standkit/registry.py +0 -0
  40. {standkit-0.6.0 → standkit-0.6.1}/standkit/secrets.py +0 -0
  41. {standkit-0.6.0 → standkit-0.6.1}/standkit.egg-info/dependency_links.txt +0 -0
  42. {standkit-0.6.0 → standkit-0.6.1}/standkit.egg-info/entry_points.txt +0 -0
  43. {standkit-0.6.0 → standkit-0.6.1}/standkit.egg-info/requires.txt +0 -0
  44. {standkit-0.6.0 → standkit-0.6.1}/standkit.egg-info/top_level.txt +0 -0
  45. {standkit-0.6.0 → standkit-0.6.1}/standkit_agent/__main__.py +0 -0
  46. {standkit-0.6.0 → standkit-0.6.1}/standkit_agent/security.py +0 -0
  47. {standkit-0.6.0 → standkit-0.6.1}/standkit_agent/server.py +0 -0
  48. {standkit-0.6.0 → standkit-0.6.1}/standkit_hub/__init__.py +0 -0
  49. {standkit-0.6.0 → standkit-0.6.1}/standkit_hub/agent_control.py +0 -0
  50. {standkit-0.6.0 → standkit-0.6.1}/standkit_hub/assets/bpmkit-icon.ico +0 -0
  51. {standkit-0.6.0 → standkit-0.6.1}/standkit_hub/assets/icon.png +0 -0
  52. {standkit-0.6.0 → standkit-0.6.1}/standkit_hub/client.py +0 -0
  53. {standkit-0.6.0 → standkit-0.6.1}/standkit_hub/config.py +0 -0
  54. {standkit-0.6.0 → standkit-0.6.1}/standkit_hub/logs_browser.py +0 -0
  55. {standkit-0.6.0 → standkit-0.6.1}/standkit_hub/poller.py +0 -0
  56. {standkit-0.6.0 → standkit-0.6.1}/standkit_hub/redis_min.py +0 -0
  57. {standkit-0.6.0 → standkit-0.6.1}/standkit_hub/security.py +0 -0
  58. {standkit-0.6.0 → standkit-0.6.1}/standkit_hub/shortcut.py +0 -0
  59. {standkit-0.6.0 → standkit-0.6.1}/standkit_hub/web/bpmkit-logo-dark.svg +0 -0
  60. {standkit-0.6.0 → standkit-0.6.1}/standkit_hub/web/bpmkit-logo.svg +0 -0
  61. {standkit-0.6.0 → standkit-0.6.1}/standkit_hub/web/favicon.png +0 -0
  62. {standkit-0.6.0 → standkit-0.6.1}/standkit_hub/web/favicon.svg +0 -0
  63. {standkit-0.6.0 → standkit-0.6.1}/standkit_hub/web/manifest.webmanifest +0 -0
  64. {standkit-0.6.0 → standkit-0.6.1}/tests/test_adopt.py +0 -0
  65. {standkit-0.6.0 → standkit-0.6.1}/tests/test_health.py +0 -0
  66. {standkit-0.6.0 → standkit-0.6.1}/tests/test_hosting.py +0 -0
  67. {standkit-0.6.0 → standkit-0.6.1}/tests/test_hub_agent_control.py +0 -0
  68. {standkit-0.6.0 → standkit-0.6.1}/tests/test_hub_config.py +0 -0
  69. {standkit-0.6.0 → standkit-0.6.1}/tests/test_hub_fast_paint.py +0 -0
  70. {standkit-0.6.0 → standkit-0.6.1}/tests/test_hub_logs_browser.py +0 -0
  71. {standkit-0.6.0 → standkit-0.6.1}/tests/test_hub_pwa_and_compact.py +0 -0
  72. {standkit-0.6.0 → standkit-0.6.1}/tests/test_hub_redis_min.py +0 -0
  73. {standkit-0.6.0 → standkit-0.6.1}/tests/test_hub_register.py +0 -0
  74. {standkit-0.6.0 → standkit-0.6.1}/tests/test_hub_server.py +0 -0
  75. {standkit-0.6.0 → standkit-0.6.1}/tests/test_hub_shortcut.py +0 -0
  76. {standkit-0.6.0 → standkit-0.6.1}/tests/test_lifecycle.py +0 -0
  77. {standkit-0.6.0 → standkit-0.6.1}/tests/test_logs.py +0 -0
  78. {standkit-0.6.0 → standkit-0.6.1}/tests/test_models.py +0 -0
  79. {standkit-0.6.0 → standkit-0.6.1}/tests/test_registry.py +0 -0
  80. {standkit-0.6.0 → standkit-0.6.1}/tests/test_registry_resolver.py +0 -0
  81. {standkit-0.6.0 → standkit-0.6.1}/tests/test_secrets.py +0 -0
@@ -1,21 +1,21 @@
1
- MIT License
2
-
3
- Copyright (c) 2026 standkit contributors
4
-
5
- Permission is hereby granted, free of charge, to any person obtaining a copy
6
- of this software and associated documentation files (the "Software"), to deal
7
- in the Software without restriction, including without limitation the rights
8
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
- copies of the Software, and to permit persons to whom the Software is
10
- furnished to do so, subject to the following conditions:
11
-
12
- The above copyright notice and this permission notice shall be included in all
13
- copies or substantial portions of the Software.
14
-
15
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
- SOFTWARE.
1
+ MIT License
2
+
3
+ Copyright (c) 2026 standkit contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: standkit
3
- Version: 0.6.0
3
+ Version: 0.6.1
4
4
  Summary: Свободное ядро (MIT) для управления жизненным циклом локальных и удалённых стендов BPMSoft: headless start/stop/restart, health-пробы, логи, реестр стендов. Часть экосистемы BPMkit.
5
5
  Author: standkit contributors
6
6
  License-Expression: MIT
@@ -61,6 +61,8 @@ TCP-порту** — их достаточно для дашборда.
61
61
  | Ярлык на рабочем столе для **macOS** | осознанно не реализовано — возвращается понятный статус | `standkit_hub/shortcut.py` (докстринг) |
62
62
  | Нативный трей (иконка со сводным состоянием, баллоны при падении) | не реализовано — потребовало бы `pystray`/`Pillow` против инварианта stdlib-only. Вместо него PWA-установка и компактный режим `?view=compact` | `standkit_hub/web/manifest.webmanifest` |
63
63
  | Автозапуск хаба при входе в систему | не реализовано; механика ярлыка есть | `standkit_hub/shortcut.py` |
64
+ | Остановка хаба, когда дашборд никто не смотрит (idle-shutdown / heartbeat от фронта) | не реализовано — запущенный ярлыком процесс живёт под `pythonw` без окна до перезагрузки или ручного снятия. Дубли при повторном клике по ярлыку закрыты single-instance проверкой, но сам «вечный» процесс остаётся | `standkit_hub/__main__.py` (`serve_forever` без таймаута), `standkit_hub/server.py::probe_hub_instance` |
65
+ | Остановка хаба из интерфейса (кнопка «Завершить работу диспетчера») | не реализовано — единственный штатный способ Ctrl+C недоступен при запуске ярлыком (консоли нет) | `standkit_hub/__main__.py` (обработка `KeyboardInterrupt`) |
64
66
 
65
67
  ## Секреты
66
68
 
@@ -3,6 +3,27 @@
3
3
  Формат — [Keep a Changelog](https://keepachangelog.com/ru/1.1.0/); версии —
4
4
  [SemVer](https://semver.org/lang/ru/). Даты — 2026 год.
5
5
 
6
+ ## [0.6.1] — 2026-07-27
7
+
8
+ ### Исправлено
9
+ - **Повторный запуск по ярлыку больше не поднимает второй диспетчер.** Ярлык
10
+ стартует хаб через `pythonw.exe`, окна нет, а закрытие браузера процесс не
11
+ останавливает — idle-shutdown у хаба нет. Поэтому второй клик по ярлыку
12
+ запускал ещё один экземпляр: два фоновых поллера дёргали health-пробы и
13
+ внешние команды бэкендов хостинга над одним `projects.json`, а окно
14
+ открывалось на другом origin со своей копией localStorage (сбрасывалась
15
+ тема). На Windows было хуже, чем предполагалось: из-за `SO_REUSEADDR` bind
16
+ на уже слушаемый 8770 проходил успешно, и два хаба молча делили один порт.
17
+ Теперь перед стартом проверяется, не работает ли диспетчер уже — если да,
18
+ просто открывается браузер на нём. Откат на свободный порт сохранён для
19
+ случая, когда 8770 занял чужой сервис.
20
+ - **Дашборд честно сообщает, что диспетчер остановлен.** Раньше вкладка с
21
+ умершим хабом продолжала показывать последний снапшот как живой, а
22
+ единственными признаками были красная точка в углу и браузерное
23
+ `Failed to fetch` в тостах. Теперь показывается баннер с объяснением и тем,
24
+ что делать, устаревшая таблица гасится, а сетевые ошибки и 401 («сессия не
25
+ подтверждена» после перезапуска диспетчера) переведены на понятный язык.
26
+
6
27
  ## [0.6.0] — 2026-07-27
7
28
 
8
29
  ### Добавлено
@@ -104,6 +125,7 @@
104
125
  - Первый публичный релиз ядра `standkit` + агента `standkit_agent` + веб-дашборда
105
126
  `standkit_hub` на PyPI; фронт-README, `docs/REMOTE_STANDS.md`, скриншоты.
106
127
 
128
+ [0.6.1]: https://pypi.org/project/standkit/0.6.1/
107
129
  [0.6.0]: https://pypi.org/project/standkit/0.6.0/
108
130
  [0.5.2]: https://pypi.org/project/standkit/0.5.2/
109
131
  [0.5.1]: https://pypi.org/project/standkit/0.5.1/
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "standkit"
7
- version = "0.6.0"
7
+ version = "0.6.1"
8
8
  description = "Свободное ядро (MIT) для управления жизненным циклом локальных и удалённых стендов BPMSoft: headless start/stop/restart, health-пробы, логи, реестр стендов. Часть экосистемы BPMkit."
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -6,6 +6,6 @@ standkit — свободное (MIT) ядро управления жизнен
6
6
  и docs/ARCHITECTURE.md в корне репозитория.
7
7
  """
8
8
 
9
- __version__ = "0.6.0"
9
+ __version__ = "0.6.1"
10
10
 
11
11
  __all__ = ["__version__"]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: standkit
3
- Version: 0.6.0
3
+ Version: 0.6.1
4
4
  Summary: Свободное ядро (MIT) для управления жизненным циклом локальных и удалённых стендов BPMSoft: headless start/stop/restart, health-пробы, логи, реестр стендов. Часть экосистемы BPMkit.
5
5
  Author: standkit contributors
6
6
  License-Expression: MIT
@@ -70,6 +70,7 @@ tests/test_hub_redis_min.py
70
70
  tests/test_hub_register.py
71
71
  tests/test_hub_server.py
72
72
  tests/test_hub_shortcut.py
73
+ tests/test_hub_single_instance.py
73
74
  tests/test_lifecycle.py
74
75
  tests/test_logs.py
75
76
  tests/test_models.py
@@ -1,9 +1,9 @@
1
- """
2
- standkit_agent — лёгкий headless-демон на хосте стенда: ядро standkit +
3
- крошечный HTTP/RPC сервер (stdlib-only: http.server, subprocess, socket,
4
- urllib). Кроссплатформенный (Windows/Linux). Без Qt и без сторонних
5
- зависимостей — специально, чтобы агент можно было развернуть на "голом"
6
- хосте стенда без сборки колёс под конкретную ОС/архитектуру.
7
- """
8
-
9
- __all__: list[str] = []
1
+ """
2
+ standkit_agent — лёгкий headless-демон на хосте стенда: ядро standkit +
3
+ крошечный HTTP/RPC сервер (stdlib-only: http.server, subprocess, socket,
4
+ urllib). Кроссплатформенный (Windows/Linux). Без Qt и без сторонних
5
+ зависимостей — специально, чтобы агент можно было развернуть на "голом"
6
+ хосте стенда без сборки колёс под конкретную ОС/архитектуру.
7
+ """
8
+
9
+ __all__: list[str] = []
@@ -1,93 +1,93 @@
1
- """
2
- Структурный append-only аудит-лог агента (JSON-lines) — обязателен для
3
- DevSecOps-эксплуатации headless-агента, управляющего процессами на хосте
4
- стенда.
5
-
6
- Формат одной строки (JSON-объект, без вложенных переводов строк):
7
- {
8
- "ts": "2026-07-23T12:34:56.789012+00:00", # UTC ISO-8601
9
- "src_ip": "10.0.0.5",
10
- "identity": "control", # control|readonly|<CN>|"-"
11
- "method": "POST",
12
- "path": "/stand/demo/restart",
13
- "action": "restart",
14
- "result": "ok", # ok|denied|error
15
- "code": 200
16
- }
17
-
18
- Секреты/токены НИКОГДА не попадают в аудит-запись — только идентичность
19
- уровня "какой скоуп/CN использован", не сам токен.
20
-
21
- STDLIB-ONLY: используется ``logging`` с отдельным именованным логгером и
22
- файловым хендлером (не корневой логгер — чтобы не смешиваться с чужой
23
- конфигурацией логирования встраивающего процесса).
24
- """
25
-
26
- from __future__ import annotations
27
-
28
- import json
29
- import logging
30
- from datetime import datetime, timezone
31
- from pathlib import Path
32
- from typing import Optional
33
-
34
- DEFAULT_AUDIT_LOG_PATH = Path.home() / ".standkit" / "audit.log"
35
-
36
- _LOGGER_NAME = "standkit_agent.audit"
37
-
38
-
39
- def build_audit_logger(path: Optional[Path] = None) -> logging.Logger:
40
- """
41
- Возвращает (создавая при необходимости) файловый логгер аудита.
42
-
43
- Идемпотентно: повторный вызов с тем же путём не плодит дублирующиеся
44
- хендлеры (полезно в тестах, где ``run_server``/фабрики обработчика могут
45
- вызываться многократно в одном процессе).
46
- """
47
- p = Path(path) if path else DEFAULT_AUDIT_LOG_PATH
48
- p.parent.mkdir(parents=True, exist_ok=True)
49
-
50
- logger_name = f"{_LOGGER_NAME}.{abs(hash(str(p.resolve())))}"
51
- logger = logging.getLogger(logger_name)
52
- logger.setLevel(logging.INFO)
53
- logger.propagate = False # не утекает в root-логгер встраивающего процесса
54
-
55
- already_attached = any(
56
- isinstance(h, logging.FileHandler) and Path(h.baseFilename) == p.resolve()
57
- for h in logger.handlers
58
- )
59
- if not already_attached:
60
- handler = logging.FileHandler(p, encoding="utf-8")
61
- handler.setFormatter(logging.Formatter("%(message)s"))
62
- logger.addHandler(handler)
63
- return logger
64
-
65
-
66
- def audit_event(
67
- logger: logging.Logger,
68
- *,
69
- src_ip: str,
70
- identity: str,
71
- method: str,
72
- path: str,
73
- action: str,
74
- result: str,
75
- code: int,
76
- ) -> None:
77
- """Пишет одну JSON-строку аудита. Никогда не бросает исключений наружу."""
78
- entry = {
79
- "ts": datetime.now(timezone.utc).isoformat(),
80
- "src_ip": src_ip,
81
- "identity": identity or "-",
82
- "method": method,
83
- "path": path,
84
- "action": action,
85
- "result": result,
86
- "code": code,
87
- }
88
- try:
89
- logger.info(json.dumps(entry, ensure_ascii=False))
90
- except Exception:
91
- # Аудит-лог не должен ронять обработку запроса ни при каких
92
- # обстоятельствах (диск полон, права на файл и т.п.).
93
- pass
1
+ """
2
+ Структурный append-only аудит-лог агента (JSON-lines) — обязателен для
3
+ DevSecOps-эксплуатации headless-агента, управляющего процессами на хосте
4
+ стенда.
5
+
6
+ Формат одной строки (JSON-объект, без вложенных переводов строк):
7
+ {
8
+ "ts": "2026-07-23T12:34:56.789012+00:00", # UTC ISO-8601
9
+ "src_ip": "10.0.0.5",
10
+ "identity": "control", # control|readonly|<CN>|"-"
11
+ "method": "POST",
12
+ "path": "/stand/demo/restart",
13
+ "action": "restart",
14
+ "result": "ok", # ok|denied|error
15
+ "code": 200
16
+ }
17
+
18
+ Секреты/токены НИКОГДА не попадают в аудит-запись — только идентичность
19
+ уровня "какой скоуп/CN использован", не сам токен.
20
+
21
+ STDLIB-ONLY: используется ``logging`` с отдельным именованным логгером и
22
+ файловым хендлером (не корневой логгер — чтобы не смешиваться с чужой
23
+ конфигурацией логирования встраивающего процесса).
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import json
29
+ import logging
30
+ from datetime import datetime, timezone
31
+ from pathlib import Path
32
+ from typing import Optional
33
+
34
+ DEFAULT_AUDIT_LOG_PATH = Path.home() / ".standkit" / "audit.log"
35
+
36
+ _LOGGER_NAME = "standkit_agent.audit"
37
+
38
+
39
+ def build_audit_logger(path: Optional[Path] = None) -> logging.Logger:
40
+ """
41
+ Возвращает (создавая при необходимости) файловый логгер аудита.
42
+
43
+ Идемпотентно: повторный вызов с тем же путём не плодит дублирующиеся
44
+ хендлеры (полезно в тестах, где ``run_server``/фабрики обработчика могут
45
+ вызываться многократно в одном процессе).
46
+ """
47
+ p = Path(path) if path else DEFAULT_AUDIT_LOG_PATH
48
+ p.parent.mkdir(parents=True, exist_ok=True)
49
+
50
+ logger_name = f"{_LOGGER_NAME}.{abs(hash(str(p.resolve())))}"
51
+ logger = logging.getLogger(logger_name)
52
+ logger.setLevel(logging.INFO)
53
+ logger.propagate = False # не утекает в root-логгер встраивающего процесса
54
+
55
+ already_attached = any(
56
+ isinstance(h, logging.FileHandler) and Path(h.baseFilename) == p.resolve()
57
+ for h in logger.handlers
58
+ )
59
+ if not already_attached:
60
+ handler = logging.FileHandler(p, encoding="utf-8")
61
+ handler.setFormatter(logging.Formatter("%(message)s"))
62
+ logger.addHandler(handler)
63
+ return logger
64
+
65
+
66
+ def audit_event(
67
+ logger: logging.Logger,
68
+ *,
69
+ src_ip: str,
70
+ identity: str,
71
+ method: str,
72
+ path: str,
73
+ action: str,
74
+ result: str,
75
+ code: int,
76
+ ) -> None:
77
+ """Пишет одну JSON-строку аудита. Никогда не бросает исключений наружу."""
78
+ entry = {
79
+ "ts": datetime.now(timezone.utc).isoformat(),
80
+ "src_ip": src_ip,
81
+ "identity": identity or "-",
82
+ "method": method,
83
+ "path": path,
84
+ "action": action,
85
+ "result": result,
86
+ "code": code,
87
+ }
88
+ try:
89
+ logger.info(json.dumps(entry, ensure_ascii=False))
90
+ except Exception:
91
+ # Аудит-лог не должен ронять обработку запроса ни при каких
92
+ # обстоятельствах (диск полон, права на файл и т.п.).
93
+ pass
@@ -8,7 +8,10 @@
8
8
  (схема+хост+ПОРТ) является ключом браузерного localStorage и HTTP-кэша, и на
9
9
  прежнем эфемерном порту каждый запуск давал новый origin — тема не
10
10
  запоминалась, кэш статики был вечно холодным, закладка протухала. Если порт
11
- занят, хаб не падает, а откатывается на эфемерный и честно об этом пишет
11
+ занят, хаб не падает: сначала проверяет, не занял ли его НАШ ЖЕ работающий
12
+ экземпляр (single-instance, см. ``server.probe_hub_instance``) — тогда просто
13
+ открывает браузер на нём и выходит, не плодя второй фоновый поллер; если порт
14
+ занял чужой сервис — откатывается на эфемерный и честно об этом пишет
12
15
  (``--port 0`` — явная просьба эфемерного порта). Флаг
13
16
  ``--desktop`` — опциональная нативная оболочка через ``pywebview`` (extra
14
17
  ``standkit[desktop]``); при отсутствии пакета хаб печатает понятное
@@ -30,7 +33,7 @@ from pathlib import Path
30
33
 
31
34
  from standkit_hub.config import HubConfig
32
35
  from standkit_hub.security import InsecureBindError, generate_session_token
33
- from standkit_hub.server import DEFAULT_HUB_PORT, bind_hub_server
36
+ from standkit_hub.server import DEFAULT_HUB_PORT, HubAlreadyRunning, bind_hub_server
34
37
  from standkit_hub.shortcut import install_desktop_shortcut, uninstall_desktop_shortcut
35
38
 
36
39
 
@@ -114,6 +117,22 @@ def main(argv: list[str] | None = None) -> int:
114
117
  insecure=args.insecure,
115
118
  on_fallback=_report_port_busy,
116
119
  )
120
+ except HubAlreadyRunning as exc:
121
+ # Второй запуск по ярлыку (обычный сценарий: окно браузера закрыли, а
122
+ # процесс под pythonw остался жить — idle-shutdown у хаба нет). Второй
123
+ # сервер здесь не нужен и вреден: два фоновых поллера над одним
124
+ # реестром плюс разъехавшийся localStorage на другом origin. Просто
125
+ # открываем браузер на уже работающем экземпляре.
126
+ #
127
+ # Токен в URL НЕ подставляем — у нас его нет (он сгенерирован в чужом
128
+ # процессе). Работающий экземпляр узнает браузер по сессионной cookie,
129
+ # выданной при первом открытии; если cookie не пережила полное
130
+ # закрытие браузера, дашборд честно скажет об этом (см. app.js,
131
+ # разбор 401), и достаточно перезапустить диспетчер.
132
+ print(f"[standkit-hub] диспетчер уже работает на {exc.host}:{exc.port} — открываю его, второй не запускаю")
133
+ if not args.no_browser:
134
+ webbrowser.open(exc.url)
135
+ return 0
117
136
  except InsecureBindError as exc:
118
137
  print(f"[standkit-hub] {exc}", file=sys.stderr)
119
138
  return 1
@@ -86,6 +86,10 @@ from standkit_hub.shortcut import install_desktop_shortcut, uninstall_desktop_sh
86
86
  # см. ``bind_hub_server``: откат на эфемерный с явным сообщением, а не падение.
87
87
  DEFAULT_HUB_PORT = 8770
88
88
 
89
+ # Заголовок-опознаватель хаба, который ставится на КАЖДЫЙ ответ (см.
90
+ # ``Handler.end_headers``) и читается ``probe_hub_instance``.
91
+ HUB_IDENTITY_HEADER = "X-Standkit-Hub"
92
+
89
93
  _STAND_ACTION_RE = re.compile(
90
94
  r"^/api/stand/(?P<name>[^/]+)/(?P<action>status|logs|start|stop|restart|adopt|state|redis-clear)$"
91
95
  )
@@ -553,6 +557,17 @@ def make_handler(
553
557
  # чтобы не шуметь секретными путями (/api/secret/<ref>) в консоли.
554
558
  pass
555
559
 
560
+ def end_headers(self) -> None:
561
+ # Метка «здесь живой standkit-hub» на КАЖДОМ ответе, включая 401 и
562
+ # 404. Нужна single-instance проверке (см. probe_hub_instance):
563
+ # второй запуск по ярлыку должен отличить свой уже работающий
564
+ # экземпляр от чужого сервиса, занявшего порт, НЕ предъявляя
565
+ # токена — а значит, судить можно только по неаутентифицированному
566
+ # ответу. Версия в значении — диагностика, не контракт: сравнивать
567
+ # её не нужно, важен сам факт заголовка.
568
+ self.send_header(HUB_IDENTITY_HEADER, _standkit_version)
569
+ super().end_headers()
570
+
556
571
  def _send_json(self, code: int, payload: dict) -> None:
557
572
  body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
558
573
  self.send_response(code)
@@ -1713,6 +1728,73 @@ def create_hub_server(
1713
1728
  _PORT_BUSY_ERRNOS = frozenset({errno.EADDRINUSE, errno.EACCES})
1714
1729
 
1715
1730
 
1731
+ def probe_hub_instance(host: str, port: int, *, timeout: float = 1.5) -> Optional[str]:
1732
+ """
1733
+ Отвечает ли на ``host:port`` уже работающий standkit-hub?
1734
+
1735
+ Возвращает строку версии из заголовка ``X-Standkit-Hub`` либо ``None``,
1736
+ если порт занят чем-то другим (или не отвечает вовремя).
1737
+
1738
+ ЗАЧЕМ. Ярлык на рабочем столе запускает хаб через ``pythonw.exe`` — без
1739
+ консоли. Закрытие окна браузера НЕ останавливает процесс (сервер живёт в
1740
+ ``serve_forever``, idle-shutdown нет), поэтому повторный клик по ярлыку
1741
+ раньше поднимал ВТОРОЙ экземпляр: порт 8770 занят → откат на эфемерный →
1742
+ два фоновых поллера дёргают health-пробы и ``subprocess`` бэкендов
1743
+ хостинга над одним ``projects.json``, а origin у второго окна другой, то
1744
+ есть своя копия localStorage. Сообщение об откате уходило в ``stderr``,
1745
+ которого при ``pythonw`` никто не видит.
1746
+
1747
+ Запрос НАМЕРЕННО неаутентифицированный: сессионного токена работающего
1748
+ экземпляра у нас нет и быть не может. Поэтому опознание идёт по
1749
+ заголовку, который хаб ставит на любой ответ, включая 401 — отдавать
1750
+ токен или иную чувствительную информацию в обмен на «постучались с
1751
+ loopback» нельзя, это был бы обход аутентификации.
1752
+
1753
+ Сетевые ошибки здесь не исключение, а нормальный ответ «не хаб»: чужой
1754
+ сервис может рвать соединение, отвечать мусором или молчать. Любой сбой
1755
+ трактуем консервативно — ``None``, то есть «не наш», и вызывающий уходит
1756
+ в прежний откат на эфемерный порт.
1757
+ """
1758
+ import http.client
1759
+
1760
+ conn = None
1761
+ try:
1762
+ conn = http.client.HTTPConnection(host, port, timeout=timeout)
1763
+ # HEAD, а не GET: опознавательный заголовок есть и здесь, а тело
1764
+ # (index.html целиком) читать незачем.
1765
+ conn.request("HEAD", "/")
1766
+ resp = conn.getresponse()
1767
+ value = resp.getheader(HUB_IDENTITY_HEADER)
1768
+ return value or None
1769
+ except Exception:
1770
+ return None
1771
+ finally:
1772
+ if conn is not None:
1773
+ try:
1774
+ conn.close()
1775
+ except Exception:
1776
+ pass
1777
+
1778
+
1779
+ class HubAlreadyRunning(RuntimeError):
1780
+ """
1781
+ На запрошенном порту уже работает standkit-hub.
1782
+
1783
+ Не ошибка в привычном смысле, а сигнал вызывающему (CLI): второй процесс
1784
+ поднимать не нужно, надо открыть браузер на уже работающем экземпляре.
1785
+ """
1786
+
1787
+ def __init__(self, host: str, port: int, version: Optional[str] = None) -> None:
1788
+ self.host = host
1789
+ self.port = port
1790
+ self.version = version
1791
+ super().__init__(f"диспетчер уже работает на {host}:{port}")
1792
+
1793
+ @property
1794
+ def url(self) -> str:
1795
+ return f"http://{self.host}:{self.port}/"
1796
+
1797
+
1716
1798
  def bind_hub_server(
1717
1799
  host: str,
1718
1800
  port: int,
@@ -1723,6 +1805,7 @@ def bind_hub_server(
1723
1805
  insecure: bool = False,
1724
1806
  poll: bool = True,
1725
1807
  on_fallback: Optional[Callable[[int, OSError], None]] = None,
1808
+ single_instance: bool = True,
1726
1809
  ) -> HubHTTPServer:
1727
1810
  """
1728
1811
  ``create_hub_server`` + откат на эфемерный порт, если запрошенный занят.
@@ -1737,7 +1820,29 @@ def bind_hub_server(
1737
1820
  ``on_fallback(requested_port, exc)`` вызывается ПЕРЕД повторным bind'ом,
1738
1821
  чтобы вызывающий (CLI) мог сообщить пользователю причину. Ошибки, не
1739
1822
  связанные с занятостью порта, пробрасываются как есть — «честный отказ».
1823
+
1824
+ ``single_instance=True`` (по умолчанию): перед bind'ом проверяем, не занял
1825
+ ли порт НАШ ЖЕ работающий хаб (см. ``probe_hub_instance``) — если да,
1826
+ второй экземпляр не поднимается, бросается ``HubAlreadyRunning``, и CLI
1827
+ просто открывает браузер на уже работающем. Откат на эфемерный остаётся
1828
+ для случая «порт занял чужой сервис»: тогда второй хаб действительно нужен.
1829
+ ``single_instance=False`` возвращает прежнее безусловное поведение и
1830
+ нужен тестам, которым надо поднять два хаба подряд.
1740
1831
  """
1832
+ # Проверка ДО bind'а, а не в обработчике OSError — и это принципиально.
1833
+ # ``ThreadingHTTPServer.allow_reuse_address = 1`` (SO_REUSEADDR), а на
1834
+ # Windows SO_REUSEADDR разрешает второму процессу занять УЖЕ слушаемый
1835
+ # порт: bind проходит успешно, EADDRINUSE не возникает, и два хаба
1836
+ # оказываются на одном 8770 — входящие соединения распределяются между
1837
+ # ними непредсказуемо. То есть на Windows отката на эфемерный порт даже не
1838
+ # случалось, было тихое раздвоение (обнаружено тестом
1839
+ # tests/test_hub_single_instance.py). Единственный переносимый способ
1840
+ # узнать правду — постучаться и посмотреть, кто ответит.
1841
+ if single_instance and port != 0:
1842
+ running = probe_hub_instance(host, port)
1843
+ if running is not None:
1844
+ raise HubAlreadyRunning(host, port, running)
1845
+
1741
1846
  try:
1742
1847
  return create_hub_server(
1743
1848
  host,