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
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Запуск/остановка ЛОКАЛЬНОГО ``standkit_agent`` как дочернего процесса из хаба
|
|
3
|
+
(``standkit_hub/server.py`` — ``POST /api/agent/start``/``POST /api/agent/stop``).
|
|
4
|
+
|
|
5
|
+
Логика спавна намеренно БЕЗ веб-слоя (чистые функции + один класс-контроллер
|
|
6
|
+
поверх ``standkit.platform``), чтобы:
|
|
7
|
+
- её можно было тестировать без реального HTTP-сервера (см.
|
|
8
|
+
tests/test_hub_agent_control.py);
|
|
9
|
+
- агент по-прежнему запускался как самостоятельный процесс через
|
|
10
|
+
``sys.executable -m standkit_agent`` (не импортом в тот же процесс хаба) —
|
|
11
|
+
падение агента не должно ронять диспетчер, и наоборот.
|
|
12
|
+
|
|
13
|
+
Секреты (``token_ref``/``readonly_token_ref``) передаются агенту ТОЛЬКО как
|
|
14
|
+
ссылки (``--token-ref``/``--readonly-token-ref``) — сам standkit_agent
|
|
15
|
+
резолвит их через свой Secret-first контракт (standkit.secrets). Значения
|
|
16
|
+
секретов через argv никогда не передаются.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import sys
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Optional
|
|
25
|
+
|
|
26
|
+
from standkit.platform import ProcessError, is_alive, spawn_hidden, stop
|
|
27
|
+
from standkit_hub.config import HubConfig
|
|
28
|
+
|
|
29
|
+
_PID_FILE_NAME = "standkit-hub-agent.pid"
|
|
30
|
+
_LOG_FILE_NAME = "standkit-hub-agent.log"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class AgentControlError(Exception):
|
|
34
|
+
"""Ошибка запуска/остановки локального агента (сообщение пригодно для показа пользователю)."""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def build_agent_argv(config: HubConfig, *, python_executable: Optional[str] = None) -> list[str]:
|
|
38
|
+
"""
|
|
39
|
+
Собирает argv команды запуска ``standkit_agent`` по полям ``HubConfig``.
|
|
40
|
+
|
|
41
|
+
Чистая функция без побочных эффектов — вся логика маппинга полей в флаги
|
|
42
|
+
вынесена сюда специально, чтобы её можно было проверить в тесте без
|
|
43
|
+
реального subprocess.Popen.
|
|
44
|
+
"""
|
|
45
|
+
python_executable = python_executable or sys.executable
|
|
46
|
+
argv = [
|
|
47
|
+
python_executable,
|
|
48
|
+
"-m",
|
|
49
|
+
"standkit_agent",
|
|
50
|
+
"--host",
|
|
51
|
+
config.agent_host,
|
|
52
|
+
"--port",
|
|
53
|
+
str(config.agent_port),
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
if config.registry_path:
|
|
57
|
+
argv += ["--registry", config.registry_path]
|
|
58
|
+
|
|
59
|
+
# token_ref обязателен для standkit_agent (required=True в его argparse) —
|
|
60
|
+
# но здесь мы не форсируем это молча: validate_agent_config() отдельно
|
|
61
|
+
# проверяет обязательность ДО спавна, чтобы дать понятную ошибку в хабе, а
|
|
62
|
+
# не "агент упал сразу после старта" без объяснений.
|
|
63
|
+
if config.token_ref:
|
|
64
|
+
argv += ["--token-ref", config.token_ref]
|
|
65
|
+
if config.readonly_token_ref:
|
|
66
|
+
argv += ["--readonly-token-ref", config.readonly_token_ref]
|
|
67
|
+
|
|
68
|
+
if config.run_dir:
|
|
69
|
+
argv += ["--run-dir", config.run_dir]
|
|
70
|
+
if config.log_dir:
|
|
71
|
+
argv += ["--log-dir", config.log_dir]
|
|
72
|
+
if config.audit_log:
|
|
73
|
+
argv += ["--audit-log", config.audit_log]
|
|
74
|
+
|
|
75
|
+
if config.tls_cert:
|
|
76
|
+
argv += ["--tls-cert", config.tls_cert]
|
|
77
|
+
if config.tls_key:
|
|
78
|
+
argv += ["--tls-key", config.tls_key]
|
|
79
|
+
if config.tls_client_ca:
|
|
80
|
+
argv += ["--tls-client-ca", config.tls_client_ca]
|
|
81
|
+
|
|
82
|
+
if config.insecure:
|
|
83
|
+
argv.append("--insecure")
|
|
84
|
+
|
|
85
|
+
argv += ["--lockout-max-failures", str(config.lockout_max_failures)]
|
|
86
|
+
argv += ["--lockout-window", str(config.lockout_window_sec)]
|
|
87
|
+
|
|
88
|
+
return argv
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def validate_agent_config(config: HubConfig) -> list[str]:
|
|
92
|
+
"""
|
|
93
|
+
Возвращает список понятных проблем конфигурации ДО попытки запуска
|
|
94
|
+
(пустой список — можно запускать). Не бросает исключений — вызывающий
|
|
95
|
+
код (хаб) сам решает, как показать список пользователю.
|
|
96
|
+
"""
|
|
97
|
+
problems: list[str] = []
|
|
98
|
+
if not config.token_ref:
|
|
99
|
+
problems.append(
|
|
100
|
+
"Не задана ссылка на control-токен агента (token_ref) — заполните её на "
|
|
101
|
+
"панели «Агент по умолчанию» и задайте сам секрет через редактор секретов."
|
|
102
|
+
)
|
|
103
|
+
if not config.registry_path:
|
|
104
|
+
problems.append("Не задан путь к реестру стендов (registry_path).")
|
|
105
|
+
return problems
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@dataclass
|
|
109
|
+
class AgentStartResult:
|
|
110
|
+
pid: int
|
|
111
|
+
log_path: str
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class AgentController:
|
|
115
|
+
"""
|
|
116
|
+
Управляет ОДНИМ локальным процессом ``standkit_agent``, запущенным из хаба.
|
|
117
|
+
|
|
118
|
+
pid запущенного процесса персистится в pid-файле (в ``run_dir`` конфига,
|
|
119
|
+
либо ``~/.standkit/run``), чтобы диспетчер мог обнаружить уже запущенный
|
|
120
|
+
им ранее процесс и после собственного перезапуска (не только в течение
|
|
121
|
+
жизни одного объекта Python).
|
|
122
|
+
"""
|
|
123
|
+
|
|
124
|
+
def __init__(self, config: HubConfig):
|
|
125
|
+
self.config = config
|
|
126
|
+
self._pid: Optional[int] = None
|
|
127
|
+
|
|
128
|
+
# --- пути ---
|
|
129
|
+
|
|
130
|
+
def _run_dir(self) -> Path:
|
|
131
|
+
return Path(self.config.run_dir) if self.config.run_dir else Path.home() / ".standkit" / "run"
|
|
132
|
+
|
|
133
|
+
def _log_dir(self) -> Path:
|
|
134
|
+
return Path(self.config.log_dir) if self.config.log_dir else Path.home() / ".standkit" / "logs"
|
|
135
|
+
|
|
136
|
+
def _pid_file(self) -> Path:
|
|
137
|
+
return self._run_dir() / _PID_FILE_NAME
|
|
138
|
+
|
|
139
|
+
def _log_file(self) -> Path:
|
|
140
|
+
return self._log_dir() / _LOG_FILE_NAME
|
|
141
|
+
|
|
142
|
+
def _load_pid(self) -> Optional[int]:
|
|
143
|
+
if self._pid is not None:
|
|
144
|
+
return self._pid
|
|
145
|
+
pid_file = self._pid_file()
|
|
146
|
+
if not pid_file.exists():
|
|
147
|
+
return None
|
|
148
|
+
try:
|
|
149
|
+
self._pid = int(pid_file.read_text(encoding="utf-8").strip())
|
|
150
|
+
except (ValueError, OSError):
|
|
151
|
+
return None
|
|
152
|
+
return self._pid
|
|
153
|
+
|
|
154
|
+
# --- состояние ---
|
|
155
|
+
|
|
156
|
+
def is_running(self) -> bool:
|
|
157
|
+
"""Проверяет, жив ли процесс агента, ранее запущенный этим контроллером (по pid-файлу)."""
|
|
158
|
+
pid = self._load_pid()
|
|
159
|
+
if pid is None:
|
|
160
|
+
return False
|
|
161
|
+
return is_alive(pid)
|
|
162
|
+
|
|
163
|
+
# --- управление ---
|
|
164
|
+
|
|
165
|
+
def start(self) -> AgentStartResult:
|
|
166
|
+
"""
|
|
167
|
+
Запускает локальный агент. Бросает ``AgentControlError`` с понятным
|
|
168
|
+
текстом, если конфигурация невалидна (см. ``validate_agent_config``),
|
|
169
|
+
агент уже запущен, либо ОС отказала в спавне процесса — никогда не
|
|
170
|
+
роняет вызывающий код хаба исключением ОС "как есть".
|
|
171
|
+
"""
|
|
172
|
+
problems = validate_agent_config(self.config)
|
|
173
|
+
if problems:
|
|
174
|
+
raise AgentControlError("; ".join(problems))
|
|
175
|
+
if self.is_running():
|
|
176
|
+
raise AgentControlError("Локальный агент уже запущен")
|
|
177
|
+
|
|
178
|
+
argv = build_agent_argv(self.config)
|
|
179
|
+
log_path = self._log_file()
|
|
180
|
+
|
|
181
|
+
try:
|
|
182
|
+
pid = spawn_hidden(argv, Path.cwd(), log_path)
|
|
183
|
+
except ProcessError as exc:
|
|
184
|
+
raise AgentControlError(f"Не удалось запустить локальный агент: {exc}") from exc
|
|
185
|
+
|
|
186
|
+
pid_file = self._pid_file()
|
|
187
|
+
pid_file.parent.mkdir(parents=True, exist_ok=True)
|
|
188
|
+
pid_file.write_text(str(pid), encoding="utf-8")
|
|
189
|
+
self._pid = pid
|
|
190
|
+
|
|
191
|
+
return AgentStartResult(pid=pid, log_path=str(log_path))
|
|
192
|
+
|
|
193
|
+
def stop(self, *, timeout: float = 10.0) -> bool:
|
|
194
|
+
"""
|
|
195
|
+
Останавливает процесс агента (если он был запущен этим контроллером).
|
|
196
|
+
Возвращает True, если процесс на момент вызова считается остановленным
|
|
197
|
+
(в т.ч. если он и так уже не был жив).
|
|
198
|
+
"""
|
|
199
|
+
pid = self._load_pid()
|
|
200
|
+
if pid is None:
|
|
201
|
+
return True
|
|
202
|
+
|
|
203
|
+
try:
|
|
204
|
+
stopped = stop(pid, timeout=timeout)
|
|
205
|
+
except ProcessError as exc:
|
|
206
|
+
raise AgentControlError(f"Не удалось остановить локальный агент: {exc}") from exc
|
|
207
|
+
|
|
208
|
+
pid_file = self._pid_file()
|
|
209
|
+
if pid_file.exists():
|
|
210
|
+
try:
|
|
211
|
+
pid_file.unlink()
|
|
212
|
+
except OSError:
|
|
213
|
+
pass
|
|
214
|
+
self._pid = None
|
|
215
|
+
|
|
216
|
+
return stopped
|
|
Binary file
|
|
Binary file
|
standkit_hub/client.py
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Федеративный клиент хаба: агрегирует стенды из локального ядра (transport=local)
|
|
3
|
+
и с удалённых агентов (transport=agent, по HTTP) в единый список для отрисовки.
|
|
4
|
+
|
|
5
|
+
Намеренно НЕ импортирует веб-слой хаба — это чистый сетевой/доменный слой,
|
|
6
|
+
который можно тестировать и переиспользовать (например, из CLI) без
|
|
7
|
+
``http.server``. Используется только stdlib (``urllib``) — как и
|
|
8
|
+
standkit_agent.server, без сторонних HTTP-клиентов.
|
|
9
|
+
|
|
10
|
+
TODO(следующая итерация):
|
|
11
|
+
- параллельный (не последовательный) опрос агентов — сейчас FederatedClient
|
|
12
|
+
ходит к агентам по очереди, что при N агентах и таймаутах масштабируется
|
|
13
|
+
плохо; кандидат — concurrent.futures.ThreadPoolExecutor;
|
|
14
|
+
- кэширование/дебаунс частых опросов (polling из хаба по таймеру);
|
|
15
|
+
- TLS/проверка сертификата агента (см. TODO в standkit_agent.server).
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
import urllib.error
|
|
22
|
+
import urllib.request
|
|
23
|
+
from dataclasses import dataclass
|
|
24
|
+
from typing import Optional
|
|
25
|
+
|
|
26
|
+
from standkit import health, lifecycle
|
|
27
|
+
from standkit.models import StandStatus, Transport
|
|
28
|
+
from standkit.registry import Registry
|
|
29
|
+
from standkit.secrets import SecretError, get_secret
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class RemoteCallError(Exception):
|
|
34
|
+
"""Ошибка сетевого вызова к агенту (недоступен, таймаут, неверный токен и т.п.)."""
|
|
35
|
+
|
|
36
|
+
agent_url: str
|
|
37
|
+
detail: str
|
|
38
|
+
|
|
39
|
+
def __str__(self) -> str: # pragma: no cover - тривиально
|
|
40
|
+
return f"Ошибка обращения к агенту {self.agent_url}: {self.detail}"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _agent_request(agent_url: str, path: str, token: str, *, method: str = "GET", timeout: float = 5.0) -> dict:
|
|
44
|
+
url = agent_url.rstrip("/") + path
|
|
45
|
+
req = urllib.request.Request(url, method=method)
|
|
46
|
+
req.add_header("Authorization", f"Bearer {token}")
|
|
47
|
+
try:
|
|
48
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
49
|
+
return json.loads(resp.read().decode("utf-8"))
|
|
50
|
+
except urllib.error.HTTPError as exc:
|
|
51
|
+
raise RemoteCallError(agent_url, f"HTTP {exc.code}") from exc
|
|
52
|
+
except (urllib.error.URLError, TimeoutError, OSError, ValueError) as exc:
|
|
53
|
+
raise RemoteCallError(agent_url, str(exc)) from exc
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class FederatedClient:
|
|
57
|
+
"""
|
|
58
|
+
Единая точка входа для хаба: даёт список стендов реестра со статусами,
|
|
59
|
+
независимо от того, локальный стенд или он живёт за агентом.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
def __init__(self, registry: Registry):
|
|
63
|
+
self.registry = registry
|
|
64
|
+
|
|
65
|
+
def list_stand_names(self) -> list[str]:
|
|
66
|
+
return self.registry.names()
|
|
67
|
+
|
|
68
|
+
def status(self, name: str) -> StandStatus:
|
|
69
|
+
"""
|
|
70
|
+
Возвращает статус одного стенда, маршрутизируя запрос по ``transport``:
|
|
71
|
+
локально через standkit.health, либо по HTTP к соответствующему агенту.
|
|
72
|
+
"""
|
|
73
|
+
stand = self.registry.get(name)
|
|
74
|
+
|
|
75
|
+
if stand.transport == Transport.LOCAL:
|
|
76
|
+
pf = lifecycle.pidfile_path(stand)
|
|
77
|
+
return health.check_stand(stand, pidfile=pf)
|
|
78
|
+
|
|
79
|
+
if stand.transport == Transport.AGENT:
|
|
80
|
+
if not stand.agent_url or not stand.agent_secret_ref:
|
|
81
|
+
raise RemoteCallError(stand.agent_url or "?", "не задан agent_url/agent_secret_ref")
|
|
82
|
+
token = get_secret(stand.agent_secret_ref)
|
|
83
|
+
data = _agent_request(stand.agent_url, f"/stand/{name}/status", token)
|
|
84
|
+
return StandStatus.from_dict(data)
|
|
85
|
+
|
|
86
|
+
raise NotImplementedError(
|
|
87
|
+
f"Транспорт {stand.transport.value!r} для стенда '{name}' пока не реализован (TODO)"
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
def status_all(self) -> dict[str, StandStatus]:
|
|
91
|
+
"""
|
|
92
|
+
Опрашивает все стенды реестра. Ошибки отдельных стендов не прерывают
|
|
93
|
+
общий опрос — недоступный стенд получает StandStatus с UNKNOWN-пробами
|
|
94
|
+
и текстом ошибки в ``details``.
|
|
95
|
+
|
|
96
|
+
TODO: см. модульный TODO — сделать параллельным.
|
|
97
|
+
"""
|
|
98
|
+
result: dict[str, StandStatus] = {}
|
|
99
|
+
for name in self.registry.names():
|
|
100
|
+
try:
|
|
101
|
+
result[name] = self.status(name)
|
|
102
|
+
except (RemoteCallError, SecretError, NotImplementedError) as exc:
|
|
103
|
+
result[name] = StandStatus(name=name, details={"error": str(exc)})
|
|
104
|
+
return result
|
|
105
|
+
|
|
106
|
+
def start(self, name: str) -> Optional[int]:
|
|
107
|
+
"""Запускает стенд. Возвращает pid, если транспорт его предоставляет (см. ``_dispatch_action``)."""
|
|
108
|
+
return self._dispatch_action(name, "start")
|
|
109
|
+
|
|
110
|
+
def stop(self, name: str) -> Optional[bool]:
|
|
111
|
+
return self._dispatch_action(name, "stop")
|
|
112
|
+
|
|
113
|
+
def restart(self, name: str) -> Optional[int]:
|
|
114
|
+
"""Перезапускает стенд. Возвращает pid, если транспорт его предоставляет."""
|
|
115
|
+
return self._dispatch_action(name, "restart")
|
|
116
|
+
|
|
117
|
+
def _dispatch_action(self, name: str, action: str):
|
|
118
|
+
"""
|
|
119
|
+
Диспетчеризует start/stop/restart по транспорту и ПРОКИДЫВАЕТ результат
|
|
120
|
+
наверх (для "local" — то, что вернул ``standkit.lifecycle`` — pid для
|
|
121
|
+
start/restart, bool для stop; для "agent" — ``pid`` из JSON-ответа
|
|
122
|
+
агента, если есть), чтобы UI хаба мог показать pid успешного старта, а
|
|
123
|
+
не только голое "ok".
|
|
124
|
+
"""
|
|
125
|
+
stand = self.registry.get(name)
|
|
126
|
+
|
|
127
|
+
if stand.transport == Transport.LOCAL:
|
|
128
|
+
fn = getattr(lifecycle, action)
|
|
129
|
+
return fn(stand)
|
|
130
|
+
|
|
131
|
+
if stand.transport == Transport.AGENT:
|
|
132
|
+
if not stand.agent_url or not stand.agent_secret_ref:
|
|
133
|
+
raise RemoteCallError(stand.agent_url or "?", "не задан agent_url/agent_secret_ref")
|
|
134
|
+
token = get_secret(stand.agent_secret_ref)
|
|
135
|
+
data = _agent_request(stand.agent_url, f"/stand/{name}/{action}", token, method="POST")
|
|
136
|
+
return data.get("pid") if isinstance(data, dict) else None
|
|
137
|
+
|
|
138
|
+
raise NotImplementedError(
|
|
139
|
+
f"Транспорт {stand.transport.value!r} для стенда '{name}' пока не реализован (TODO)"
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
def logs(self, name: str, n: int = 100) -> list[str]:
|
|
143
|
+
stand = self.registry.get(name)
|
|
144
|
+
|
|
145
|
+
if stand.transport == Transport.LOCAL:
|
|
146
|
+
from standkit import logs as _logs
|
|
147
|
+
|
|
148
|
+
return _logs.tail(lifecycle.log_path(stand), n)
|
|
149
|
+
|
|
150
|
+
if stand.transport == Transport.AGENT:
|
|
151
|
+
if not stand.agent_url or not stand.agent_secret_ref:
|
|
152
|
+
raise RemoteCallError(stand.agent_url or "?", "не задан agent_url/agent_secret_ref")
|
|
153
|
+
token = get_secret(stand.agent_secret_ref)
|
|
154
|
+
data = _agent_request(stand.agent_url, f"/stand/{name}/logs?n={n}", token)
|
|
155
|
+
return list(data.get("lines", []))
|
|
156
|
+
|
|
157
|
+
raise NotImplementedError(
|
|
158
|
+
f"Транспорт {stand.transport.value!r} для стенда '{name}' пока не реализован (TODO)"
|
|
159
|
+
)
|
standkit_hub/config.py
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Конфиг веб-дашборда standkit (``standkit-hub.json``) — ВСЕ параметры,
|
|
3
|
+
которые иначе пришлось бы задавать флагами ``--host``/``--port``/... в
|
|
4
|
+
терминале при запуске headless-агента, плюс параметры самого хаба (реестр,
|
|
5
|
+
каталоги, интервал автообновления, список удалённых агентов федерации).
|
|
6
|
+
|
|
7
|
+
Намеренно НЕ импортирует ничего из ``http.server``/веб-слоя — модуль должен
|
|
8
|
+
быть тестируемым в изоляции (см. tests/test_hub_config.py). Отдаётся/
|
|
9
|
+
принимается фронтендом хаба через ``GET/POST /api/settings`` (см.
|
|
10
|
+
standkit_hub/server.py).
|
|
11
|
+
|
|
12
|
+
Путь конфига — та же папка BPMkit, что и реестр стендов (см.
|
|
13
|
+
standkit.registry.bpmkit_config_dir):
|
|
14
|
+
Windows: %APPDATA%\\BPMkit\\standkit-hub.json
|
|
15
|
+
POSIX: ~/.config/BPMkit/standkit-hub.json (или $XDG_CONFIG_HOME/BPMkit/...)
|
|
16
|
+
|
|
17
|
+
Секреты (control/readonly-токены агентов) в конфиге хранятся ТОЛЬКО как ссылки
|
|
18
|
+
(``*_ref``) на standkit.secrets — значения самих секретов сюда никогда не
|
|
19
|
+
попадают.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import json
|
|
25
|
+
from dataclasses import asdict, dataclass, field, fields
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import Any, Optional
|
|
28
|
+
|
|
29
|
+
from standkit.registry import bpmkit_config_dir, default_registry_path
|
|
30
|
+
|
|
31
|
+
_CONFIG_FILE_NAME = "standkit-hub.json"
|
|
32
|
+
|
|
33
|
+
# Значения по умолчанию для параметров запуска локального агента (совпадают
|
|
34
|
+
# с default'ами standkit_agent/__main__.py — см. DEFAULT_LOCKOUT_* там же).
|
|
35
|
+
_DEFAULT_AGENT_HOST = "127.0.0.1"
|
|
36
|
+
_DEFAULT_AGENT_PORT = 8765
|
|
37
|
+
_DEFAULT_LOCKOUT_MAX_FAILURES = 5
|
|
38
|
+
_DEFAULT_LOCKOUT_WINDOW_SEC = 300.0
|
|
39
|
+
_DEFAULT_REFRESH_INTERVAL_SEC = 10
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class RemoteAgent:
|
|
44
|
+
"""Одна запись федерации удалённых агентов (мульти-агентная панель хаба)."""
|
|
45
|
+
|
|
46
|
+
name: str = ""
|
|
47
|
+
url: str = ""
|
|
48
|
+
# Ссылка на секрет токена (standkit.secrets), НЕ сам токен.
|
|
49
|
+
token_ref: str = ""
|
|
50
|
+
|
|
51
|
+
@classmethod
|
|
52
|
+
def from_dict(cls, data: dict) -> "RemoteAgent":
|
|
53
|
+
return cls(
|
|
54
|
+
name=str(data.get("name", "")),
|
|
55
|
+
url=str(data.get("url", "")),
|
|
56
|
+
token_ref=str(data.get("token_ref", "")),
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
def to_dict(self) -> dict:
|
|
60
|
+
return {"name": self.name, "url": self.url, "token_ref": self.token_ref}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass
|
|
64
|
+
class HubConfig:
|
|
65
|
+
"""
|
|
66
|
+
Все настраиваемые пользователем параметры веб-дашборда, чтобы не лазить
|
|
67
|
+
в PowerShell/``--help``.
|
|
68
|
+
|
|
69
|
+
Поля сгруппированы по смыслу:
|
|
70
|
+
- реестр/каталоги/автообновление — сам хаб;
|
|
71
|
+
- agents — федерация удалённых standkit-агентов, которых показывает хаб;
|
|
72
|
+
- agent_* / tls_* / lockout_* / insecure / audit_log — дефолты для запуска
|
|
73
|
+
ЛОКАЛЬНОГО агента из хаба (зеркалят флаги standkit_agent/__main__.py
|
|
74
|
+
один в один, чтобы форма настроек их полностью покрывала).
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
# --- Хаб ---
|
|
78
|
+
registry_path: str = field(default_factory=lambda: str(default_registry_path()))
|
|
79
|
+
run_dir: str = ""
|
|
80
|
+
log_dir: str = ""
|
|
81
|
+
refresh_interval_sec: int = _DEFAULT_REFRESH_INTERVAL_SEC
|
|
82
|
+
|
|
83
|
+
# --- Федерация удалённых агентов ---
|
|
84
|
+
agents: list[RemoteAgent] = field(default_factory=list)
|
|
85
|
+
|
|
86
|
+
# --- Дефолты запуска локального агента (standkit_agent) ---
|
|
87
|
+
agent_host: str = _DEFAULT_AGENT_HOST
|
|
88
|
+
agent_port: int = _DEFAULT_AGENT_PORT
|
|
89
|
+
token_ref: str = ""
|
|
90
|
+
readonly_token_ref: str = ""
|
|
91
|
+
tls_cert: str = ""
|
|
92
|
+
tls_key: str = ""
|
|
93
|
+
tls_client_ca: str = ""
|
|
94
|
+
insecure: bool = False
|
|
95
|
+
audit_log: str = ""
|
|
96
|
+
lockout_max_failures: int = _DEFAULT_LOCKOUT_MAX_FAILURES
|
|
97
|
+
lockout_window_sec: float = _DEFAULT_LOCKOUT_WINDOW_SEC
|
|
98
|
+
|
|
99
|
+
# --- чтение/запись ---
|
|
100
|
+
|
|
101
|
+
@classmethod
|
|
102
|
+
def config_path(cls) -> Path:
|
|
103
|
+
"""Канонический путь к файлу конфига хаба (та же папка, что и реестр кита)."""
|
|
104
|
+
return bpmkit_config_dir() / _CONFIG_FILE_NAME
|
|
105
|
+
|
|
106
|
+
@classmethod
|
|
107
|
+
def load(cls, path: Optional[str | Path] = None) -> "HubConfig":
|
|
108
|
+
"""
|
|
109
|
+
Читает конфиг из ``path`` (по умолчанию — ``config_path()``).
|
|
110
|
+
|
|
111
|
+
Если файла нет — возвращает конфиг с дефолтами (в т.ч.
|
|
112
|
+
``registry_path = default_registry_path()``); это нормальная ситуация
|
|
113
|
+
при первом запуске хаба. Файл читается как ``utf-8-sig`` (терпим к BOM
|
|
114
|
+
— тот же принцип, что и в standkit.registry.Registry.load).
|
|
115
|
+
"""
|
|
116
|
+
p = Path(path) if path is not None else cls.config_path()
|
|
117
|
+
if not p.exists():
|
|
118
|
+
return cls()
|
|
119
|
+
|
|
120
|
+
raw = p.read_text(encoding="utf-8-sig")
|
|
121
|
+
try:
|
|
122
|
+
data = json.loads(raw) if raw.strip() else {}
|
|
123
|
+
except json.JSONDecodeError:
|
|
124
|
+
# Битый конфиг хаба не должен ронять запуск диспетчера — тихо
|
|
125
|
+
# откатываемся на дефолты (в отличие от реестра, это не
|
|
126
|
+
# критичные для управления стендами данные).
|
|
127
|
+
return cls()
|
|
128
|
+
|
|
129
|
+
return cls.from_dict(data)
|
|
130
|
+
|
|
131
|
+
def save(self, path: Optional[str | Path] = None) -> None:
|
|
132
|
+
"""Пишет конфиг в ``path`` (по умолчанию — ``config_path()``), создавая папку при необходимости."""
|
|
133
|
+
p = Path(path) if path is not None else self.config_path()
|
|
134
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
135
|
+
text = json.dumps(self.to_dict(), ensure_ascii=False, indent=2)
|
|
136
|
+
p.write_text(text, encoding="utf-8")
|
|
137
|
+
|
|
138
|
+
# --- сериализация ---
|
|
139
|
+
|
|
140
|
+
@classmethod
|
|
141
|
+
def from_dict(cls, data: dict) -> "HubConfig":
|
|
142
|
+
known = {f.name for f in fields(cls)}
|
|
143
|
+
kwargs: dict[str, Any] = {}
|
|
144
|
+
for key, value in data.items():
|
|
145
|
+
if key not in known:
|
|
146
|
+
continue
|
|
147
|
+
if key == "agents":
|
|
148
|
+
kwargs[key] = [RemoteAgent.from_dict(a) for a in value]
|
|
149
|
+
else:
|
|
150
|
+
kwargs[key] = value
|
|
151
|
+
return cls(**kwargs)
|
|
152
|
+
|
|
153
|
+
def to_dict(self) -> dict:
|
|
154
|
+
result = asdict(self)
|
|
155
|
+
result["agents"] = [a.to_dict() for a in self.agents]
|
|
156
|
+
return result
|