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.
@@ -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