standkit 0.3.7__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- standkit/__init__.py +11 -0
- standkit/health.py +163 -0
- standkit/lifecycle.py +191 -0
- standkit/logs.py +221 -0
- standkit/models.py +211 -0
- standkit/platform.py +174 -0
- standkit/registry.py +223 -0
- standkit/secrets.py +144 -0
- standkit-0.3.7.dist-info/METADATA +155 -0
- standkit-0.3.7.dist-info/RECORD +38 -0
- standkit-0.3.7.dist-info/WHEEL +5 -0
- standkit-0.3.7.dist-info/entry_points.txt +4 -0
- standkit-0.3.7.dist-info/licenses/LICENSE +21 -0
- standkit-0.3.7.dist-info/top_level.txt +3 -0
- standkit_agent/__init__.py +9 -0
- standkit_agent/__main__.py +192 -0
- standkit_agent/audit.py +93 -0
- standkit_agent/security.py +300 -0
- standkit_agent/server.py +386 -0
- standkit_hub/__init__.py +13 -0
- standkit_hub/__main__.py +131 -0
- standkit_hub/agent_control.py +216 -0
- standkit_hub/assets/bpmkit-icon.ico +0 -0
- standkit_hub/assets/icon.png +0 -0
- standkit_hub/client.py +159 -0
- standkit_hub/config.py +156 -0
- standkit_hub/logs_browser.py +174 -0
- standkit_hub/redis_min.py +326 -0
- standkit_hub/security.py +146 -0
- standkit_hub/server.py +881 -0
- standkit_hub/shortcut.py +264 -0
- standkit_hub/web/app.js +803 -0
- standkit_hub/web/bpmkit-logo-dark.svg +5 -0
- standkit_hub/web/bpmkit-logo.svg +5 -0
- standkit_hub/web/favicon.png +0 -0
- standkit_hub/web/favicon.svg +4 -0
- standkit_hub/web/index.html +267 -0
- standkit_hub/web/style.css +843 -0
standkit/models.py
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Модели данных ядра standkit: описание стенда (Stand) и его состояния (StandStatus).
|
|
3
|
+
|
|
4
|
+
Поля Stand буквально повторяют схему записи в реестре projects.json (см.
|
|
5
|
+
projects.sample.json в корне репозитория) плюс универсальное поле транспорта
|
|
6
|
+
``transport``, которое определяет, как ядро должно управлять стендом:
|
|
7
|
+
|
|
8
|
+
- "local" — стенд поднимается локально текущим процессом standkit (subprocess,
|
|
9
|
+
см. standkit.platform / standkit.lifecycle);
|
|
10
|
+
- "agent" — стенд управляется через удалённый standkit_agent по HTTP
|
|
11
|
+
(используются agent_url / agent_secret_ref);
|
|
12
|
+
- "ssh" / "winrm" — зарезервировано под будущие транспорты, СХЕМОЙ допускается,
|
|
13
|
+
логика НЕ реализована (см. TODO в lifecycle.py).
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from dataclasses import dataclass, field, fields
|
|
19
|
+
from enum import Enum
|
|
20
|
+
from typing import Any, Optional
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Transport(str, Enum):
|
|
24
|
+
"""Способ, которым ядро/GUI дотягивается до стенда."""
|
|
25
|
+
|
|
26
|
+
LOCAL = "local"
|
|
27
|
+
AGENT = "agent"
|
|
28
|
+
# Задел на будущее — схема допускает значения, реализации пока нет.
|
|
29
|
+
SSH = "ssh"
|
|
30
|
+
WINRM = "winrm"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class ProbeState(str, Enum):
|
|
34
|
+
"""Единое множество состояний для любой health-пробы."""
|
|
35
|
+
|
|
36
|
+
OK = "ok"
|
|
37
|
+
DOWN = "down"
|
|
38
|
+
UNKNOWN = "unknown"
|
|
39
|
+
# Проба сознательно не выполнялась (например, глубокая БД-проба выключена флагом).
|
|
40
|
+
SKIPPED = "skipped"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# Поля, обязательные для валидной записи Stand (без них смысла в объекте нет).
|
|
44
|
+
_REQUIRED_FIELDS = ("name", "stand_dir")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class Stand:
|
|
49
|
+
"""
|
|
50
|
+
Описание одного стенда BPMSoft — один к одному со схемой записи в projects.json.
|
|
51
|
+
|
|
52
|
+
``name`` — ключ записи в реестре (не хранится внутри самой записи в JSON,
|
|
53
|
+
проставляется registry.py при чтении).
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
name: str
|
|
57
|
+
|
|
58
|
+
# --- Транспорт управления стендом ---
|
|
59
|
+
transport: Transport = Transport.LOCAL
|
|
60
|
+
agent_url: Optional[str] = None
|
|
61
|
+
agent_secret_ref: Optional[str] = None
|
|
62
|
+
|
|
63
|
+
# --- Процесс стенда ---
|
|
64
|
+
stand_dir: str = ""
|
|
65
|
+
stand_dll: str = "BPMSoft.WebHost.dll"
|
|
66
|
+
dotnet: str = "dotnet"
|
|
67
|
+
stand_host: str = "127.0.0.1"
|
|
68
|
+
stand_port: int = 5000
|
|
69
|
+
|
|
70
|
+
# --- База данных ---
|
|
71
|
+
db_type: str = "postgres"
|
|
72
|
+
db_host: str = ""
|
|
73
|
+
db_port: int = 0
|
|
74
|
+
db_name: str = ""
|
|
75
|
+
db_user: str = ""
|
|
76
|
+
db_password: str = ""
|
|
77
|
+
secret_ref_db: Optional[str] = None
|
|
78
|
+
|
|
79
|
+
# --- Администратор стенда ---
|
|
80
|
+
admin_user: str = "Supervisor"
|
|
81
|
+
secret_ref_admin: Optional[str] = None
|
|
82
|
+
|
|
83
|
+
# --- Прочее ---
|
|
84
|
+
distrib_dir: str = ""
|
|
85
|
+
description: str = ""
|
|
86
|
+
customer: str = ""
|
|
87
|
+
|
|
88
|
+
# Произвольные дополнительные поля из реестра, которые ядро не знает явно,
|
|
89
|
+
# но не хочет терять при повторной записи (forward-compatibility).
|
|
90
|
+
extra: dict = field(default_factory=dict)
|
|
91
|
+
|
|
92
|
+
@classmethod
|
|
93
|
+
def from_dict(cls, name: str, data: dict) -> "Stand":
|
|
94
|
+
"""
|
|
95
|
+
Строит Stand из словаря записи реестра (как он приходит из JSON).
|
|
96
|
+
|
|
97
|
+
Неизвестные поля не теряются — уходят в ``extra``, чтобы round-trip
|
|
98
|
+
чтение→запись не терял данные, добавленные другими инструментами
|
|
99
|
+
экосистемы (например, provision_stand из BPMkit).
|
|
100
|
+
"""
|
|
101
|
+
known = {f.name for f in fields(cls)} - {"name", "extra"}
|
|
102
|
+
kwargs: dict[str, Any] = {}
|
|
103
|
+
extra: dict[str, Any] = {}
|
|
104
|
+
for key, value in data.items():
|
|
105
|
+
if key == "transport":
|
|
106
|
+
kwargs["transport"] = _coerce_transport(value)
|
|
107
|
+
elif key in known:
|
|
108
|
+
kwargs[key] = value
|
|
109
|
+
elif key == "name":
|
|
110
|
+
continue
|
|
111
|
+
else:
|
|
112
|
+
extra[key] = value
|
|
113
|
+
return cls(name=name, extra=extra, **kwargs)
|
|
114
|
+
|
|
115
|
+
def to_dict(self) -> dict:
|
|
116
|
+
"""Сериализует запись обратно в словарь для записи в projects.json (без ``name``)."""
|
|
117
|
+
result: dict[str, Any] = {}
|
|
118
|
+
for f in fields(self):
|
|
119
|
+
if f.name in ("name", "extra"):
|
|
120
|
+
continue
|
|
121
|
+
value = getattr(self, f.name)
|
|
122
|
+
if f.name == "transport":
|
|
123
|
+
value = value.value if isinstance(value, Transport) else value
|
|
124
|
+
result[f.name] = value
|
|
125
|
+
result.update(self.extra)
|
|
126
|
+
return result
|
|
127
|
+
|
|
128
|
+
def validate(self) -> list[str]:
|
|
129
|
+
"""
|
|
130
|
+
Минимальная валидация записи. Возвращает список текстов ошибок
|
|
131
|
+
(пустой список — запись валидна). Не бросает исключений намеренно —
|
|
132
|
+
вызывающий код сам решает, насколько строго реагировать.
|
|
133
|
+
"""
|
|
134
|
+
errors: list[str] = []
|
|
135
|
+
if not self.name:
|
|
136
|
+
errors.append("name не может быть пустым")
|
|
137
|
+
if not self.stand_dir:
|
|
138
|
+
errors.append("stand_dir не может быть пустым")
|
|
139
|
+
if self.transport == Transport.AGENT:
|
|
140
|
+
if not self.agent_url:
|
|
141
|
+
errors.append("transport=agent требует agent_url")
|
|
142
|
+
return errors
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _coerce_transport(value: Any) -> Transport:
|
|
146
|
+
if isinstance(value, Transport):
|
|
147
|
+
return value
|
|
148
|
+
try:
|
|
149
|
+
return Transport(str(value))
|
|
150
|
+
except ValueError:
|
|
151
|
+
# Неизвестное будущее значение транспорта — не роняем чтение реестра,
|
|
152
|
+
# оставляем как UNKNOWN-эквивалент через LOCAL с пометкой в extra.
|
|
153
|
+
return Transport.LOCAL
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
@dataclass
|
|
157
|
+
class StandStatus:
|
|
158
|
+
"""
|
|
159
|
+
Снимок состояния стенда по всем доступным пробам разом.
|
|
160
|
+
|
|
161
|
+
Каждое поле — состояние независимой пробы (см. standkit.health):
|
|
162
|
+
- process: жив ли процесс стенда (по pid-файлу);
|
|
163
|
+
- http: отвечает ли web-хост стенда по HTTP;
|
|
164
|
+
- db: открыт ли TCP-порт БД (не полноценный запрос — TODO);
|
|
165
|
+
- redis: открыт ли TCP-порт Redis (если используется);
|
|
166
|
+
- last_deploy: состояние последнего деплоя — вне ядра здоровья, задел на
|
|
167
|
+
будущее для витрины GUI (TODO: источник данных).
|
|
168
|
+
"""
|
|
169
|
+
|
|
170
|
+
name: str
|
|
171
|
+
process: ProbeState = ProbeState.UNKNOWN
|
|
172
|
+
http: ProbeState = ProbeState.UNKNOWN
|
|
173
|
+
db: ProbeState = ProbeState.UNKNOWN
|
|
174
|
+
redis: ProbeState = ProbeState.UNKNOWN
|
|
175
|
+
last_deploy: ProbeState = ProbeState.UNKNOWN
|
|
176
|
+
details: dict = field(default_factory=dict)
|
|
177
|
+
|
|
178
|
+
@classmethod
|
|
179
|
+
def from_dict(cls, data: dict) -> "StandStatus":
|
|
180
|
+
"""Строит StandStatus из словаря (например, ответа агента по HTTP)."""
|
|
181
|
+
kwargs: dict[str, Any] = {"name": data.get("name", "")}
|
|
182
|
+
for key in ("process", "http", "db", "redis", "last_deploy"):
|
|
183
|
+
if key in data:
|
|
184
|
+
kwargs[key] = _coerce_probe_state(data[key])
|
|
185
|
+
kwargs["details"] = dict(data.get("details", {}))
|
|
186
|
+
return cls(**kwargs)
|
|
187
|
+
|
|
188
|
+
def to_dict(self) -> dict:
|
|
189
|
+
return {
|
|
190
|
+
"name": self.name,
|
|
191
|
+
"process": self.process.value,
|
|
192
|
+
"http": self.http.value,
|
|
193
|
+
"db": self.db.value,
|
|
194
|
+
"redis": self.redis.value,
|
|
195
|
+
"last_deploy": self.last_deploy.value,
|
|
196
|
+
"details": self.details,
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
@property
|
|
200
|
+
def is_healthy(self) -> bool:
|
|
201
|
+
"""Грубая сводная оценка: процесс и HTTP в порядке (БД/Redis — опциональны)."""
|
|
202
|
+
return self.process == ProbeState.OK and self.http in (ProbeState.OK, ProbeState.SKIPPED)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _coerce_probe_state(value: Any) -> ProbeState:
|
|
206
|
+
if isinstance(value, ProbeState):
|
|
207
|
+
return value
|
|
208
|
+
try:
|
|
209
|
+
return ProbeState(str(value))
|
|
210
|
+
except ValueError:
|
|
211
|
+
return ProbeState.UNKNOWN
|
standkit/platform.py
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""
|
|
2
|
+
OS-абстракция запуска процессов: скрытый (headless, без консольного окна)
|
|
3
|
+
процесс на Windows, отсоединённый (setsid) процесс на Linux.
|
|
4
|
+
|
|
5
|
+
Никакого хардкода путей вида ``C:\\...`` — все пути принимаются и возвращаются
|
|
6
|
+
как ``pathlib.Path``. Модуль не знает ничего про BPMSoft — только про то, как
|
|
7
|
+
корректно поднять/остановить/проверить произвольный процесс кроссплатформенно.
|
|
8
|
+
|
|
9
|
+
TODO(следующая итерация):
|
|
10
|
+
- Windows: сейчас используется CREATE_NO_WINDOW через subprocess без
|
|
11
|
+
полноценной службы (Job Object) — процесс-потомок может пережить
|
|
12
|
+
неожиданное падение родителя без присмотра. Рассмотреть Job Object API
|
|
13
|
+
(через ctypes) для гарантированной остановки дерева процессов.
|
|
14
|
+
- Linux: полноценная демонизация (двойной fork, отвязка от сессии) —
|
|
15
|
+
сейчас используется упрощённый setsid через start_new_session=True,
|
|
16
|
+
этого достаточно для большинства случаев, но не эквивалентно классическому
|
|
17
|
+
double-fork демону.
|
|
18
|
+
- graceful stop (SIGTERM/таймаут/SIGKILL на Linux; CTRL_BREAK_EVENT на
|
|
19
|
+
Windows) — сейчас stop() сразу и безусловно завершает процесс.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import os
|
|
25
|
+
import signal
|
|
26
|
+
import subprocess
|
|
27
|
+
import sys
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
from typing import Optional, Sequence
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ProcessError(Exception):
|
|
33
|
+
"""Ошибки запуска/остановки/проверки процесса."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def spawn_hidden(cmd: Sequence[str], cwd: Path, log_path: Path) -> int:
|
|
37
|
+
"""
|
|
38
|
+
Запускает процесс в фоне, без видимого консольного окна (Windows) /
|
|
39
|
+
в новой сессии (Linux), с перенаправлением stdout+stderr в log_path.
|
|
40
|
+
|
|
41
|
+
Возвращает pid запущенного процесса. Не блокирует вызывающий поток.
|
|
42
|
+
"""
|
|
43
|
+
cwd = Path(cwd)
|
|
44
|
+
log_path = Path(log_path)
|
|
45
|
+
log_path.parent.mkdir(parents=True, exist_ok=True)
|
|
46
|
+
|
|
47
|
+
# Открываем лог в режиме дозаписи — предыдущие запуски не теряются молча.
|
|
48
|
+
log_file = open(log_path, "ab", buffering=0)
|
|
49
|
+
|
|
50
|
+
popen_kwargs: dict = {
|
|
51
|
+
"cwd": str(cwd),
|
|
52
|
+
"stdout": log_file,
|
|
53
|
+
"stderr": subprocess.STDOUT,
|
|
54
|
+
"stdin": subprocess.DEVNULL,
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if sys.platform == "win32":
|
|
58
|
+
# Скрытое окно + отдельная группа процессов, чтобы CTRL-C консоли
|
|
59
|
+
# родителя (если он в консоли) не убивал дочерний процесс стенда.
|
|
60
|
+
creationflags = 0
|
|
61
|
+
creationflags |= getattr(subprocess, "CREATE_NO_WINDOW", 0x08000000)
|
|
62
|
+
creationflags |= getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0x00000200)
|
|
63
|
+
popen_kwargs["creationflags"] = creationflags
|
|
64
|
+
else:
|
|
65
|
+
# Новая сессия — процесс переживает завершение управляющего терминала
|
|
66
|
+
# и получает свою группу для последующего управляемого stop().
|
|
67
|
+
popen_kwargs["start_new_session"] = True
|
|
68
|
+
|
|
69
|
+
try:
|
|
70
|
+
proc = subprocess.Popen(list(cmd), **popen_kwargs)
|
|
71
|
+
except OSError as exc:
|
|
72
|
+
raise ProcessError(f"Не удалось запустить процесс {cmd!r}: {exc}") from exc
|
|
73
|
+
finally:
|
|
74
|
+
log_file.close()
|
|
75
|
+
|
|
76
|
+
return proc.pid
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def is_alive(pid: int) -> bool:
|
|
80
|
+
"""Проверяет, жив ли процесс с данным pid (кроссплатформенно)."""
|
|
81
|
+
if pid <= 0:
|
|
82
|
+
return False
|
|
83
|
+
if sys.platform == "win32":
|
|
84
|
+
return _is_alive_windows(pid)
|
|
85
|
+
return _is_alive_posix(pid)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def stop(pid: int, *, timeout: float = 10.0) -> bool:
|
|
89
|
+
"""
|
|
90
|
+
Останавливает процесс по pid.
|
|
91
|
+
|
|
92
|
+
TODO: сейчас — безусловное завершение (SIGTERM/taskkill), без ожидания
|
|
93
|
+
graceful shutdown и без эскалации до SIGKILL по таймауту. Для веб-хоста
|
|
94
|
+
BPMSoft это может быть недостаточно аккуратно (незавершённые транзакции) —
|
|
95
|
+
в следующей итерации добавить: сигнал → ожидание ``timeout`` секунд с
|
|
96
|
+
опросом is_alive() → принудительное убийство, если процесс не завершился.
|
|
97
|
+
|
|
98
|
+
Возвращает True, если процесс на момент вызова считался остановленным
|
|
99
|
+
(уже не был жив, либо остановлен успешно).
|
|
100
|
+
"""
|
|
101
|
+
if not is_alive(pid):
|
|
102
|
+
return True
|
|
103
|
+
|
|
104
|
+
if sys.platform == "win32":
|
|
105
|
+
return _stop_windows(pid)
|
|
106
|
+
return _stop_posix(pid)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
# --- Windows-специфика ---
|
|
110
|
+
|
|
111
|
+
def _is_alive_windows(pid: int) -> bool:
|
|
112
|
+
try:
|
|
113
|
+
import ctypes
|
|
114
|
+
|
|
115
|
+
PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
|
|
116
|
+
handle = ctypes.windll.kernel32.OpenProcess( # type: ignore[attr-defined]
|
|
117
|
+
PROCESS_QUERY_LIMITED_INFORMATION, False, pid
|
|
118
|
+
)
|
|
119
|
+
if not handle:
|
|
120
|
+
return False
|
|
121
|
+
try:
|
|
122
|
+
exit_code = ctypes.c_ulong()
|
|
123
|
+
STILL_ACTIVE = 259
|
|
124
|
+
ok = ctypes.windll.kernel32.GetExitCodeProcess( # type: ignore[attr-defined]
|
|
125
|
+
handle, ctypes.byref(exit_code)
|
|
126
|
+
)
|
|
127
|
+
return bool(ok) and exit_code.value == STILL_ACTIVE
|
|
128
|
+
finally:
|
|
129
|
+
ctypes.windll.kernel32.CloseHandle(handle) # type: ignore[attr-defined]
|
|
130
|
+
except Exception:
|
|
131
|
+
# Фолбэк на tasklist, если ctypes-путь недоступен по какой-то причине.
|
|
132
|
+
try:
|
|
133
|
+
out = subprocess.check_output(
|
|
134
|
+
["tasklist", "/FI", f"PID eq {pid}"], text=True, stderr=subprocess.DEVNULL
|
|
135
|
+
)
|
|
136
|
+
return str(pid) in out
|
|
137
|
+
except Exception:
|
|
138
|
+
return False
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _stop_windows(pid: int) -> bool:
|
|
142
|
+
try:
|
|
143
|
+
subprocess.run(
|
|
144
|
+
["taskkill", "/PID", str(pid), "/T", "/F"],
|
|
145
|
+
check=False,
|
|
146
|
+
stdout=subprocess.DEVNULL,
|
|
147
|
+
stderr=subprocess.DEVNULL,
|
|
148
|
+
)
|
|
149
|
+
except OSError as exc:
|
|
150
|
+
raise ProcessError(f"Не удалось остановить процесс {pid}: {exc}") from exc
|
|
151
|
+
return not is_alive(pid)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
# --- Linux/POSIX-специфика ---
|
|
155
|
+
|
|
156
|
+
def _is_alive_posix(pid: int) -> bool:
|
|
157
|
+
try:
|
|
158
|
+
os.kill(pid, 0)
|
|
159
|
+
return True
|
|
160
|
+
except ProcessLookupError:
|
|
161
|
+
return False
|
|
162
|
+
except PermissionError:
|
|
163
|
+
# Процесс существует, но принадлежит другому пользователю — жив.
|
|
164
|
+
return True
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _stop_posix(pid: int) -> bool:
|
|
168
|
+
try:
|
|
169
|
+
os.kill(pid, signal.SIGTERM)
|
|
170
|
+
except ProcessLookupError:
|
|
171
|
+
return True
|
|
172
|
+
except OSError as exc:
|
|
173
|
+
raise ProcessError(f"Не удалось остановить процесс {pid}: {exc}") from exc
|
|
174
|
+
return not is_alive(pid)
|
standkit/registry.py
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Реестр стендов — чтение/запись projects.json.
|
|
3
|
+
|
|
4
|
+
Формат совпадает со схемой BPMkit (см. projects.sample.json в корне репозитория):
|
|
5
|
+
верхний уровень ``{"default": <имя>, "stands": {<имя>: {...}}}``. Файл читается как
|
|
6
|
+
``utf-8-sig`` (терпим к BOM, который оставляют некоторые редакторы на Windows) и
|
|
7
|
+
пишется БЕЗ BOM, чтобы не плодить дифф на ровном месте между инструментами.
|
|
8
|
+
|
|
9
|
+
Важно: этот модуль НЕ занимается провижинингом стендов (созданием каталогов,
|
|
10
|
+
БД, установкой дистрибутива) — только чтением/записью записей реестра для уже
|
|
11
|
+
существующих стендов. Провижининг — зона ответственности платного продукта
|
|
12
|
+
(BPMkit: provision_stand и т.п.).
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import os
|
|
19
|
+
import sys
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Iterable, Optional
|
|
22
|
+
|
|
23
|
+
from standkit.models import Stand
|
|
24
|
+
|
|
25
|
+
_DEFAULT_SCHEMA_VERSION = 1
|
|
26
|
+
|
|
27
|
+
# Имя папки с общими файлами экосистемы BPMkit (реестр стендов, конфиг GUI и
|
|
28
|
+
# т.п.) — единая точка правды между standkit и BPMkit MCP.
|
|
29
|
+
_BPMKIT_DIR_NAME = "BPMkit"
|
|
30
|
+
_REGISTRY_FILE_NAME = "projects.json"
|
|
31
|
+
_ENV_REGISTRY_VAR = "BPMSOFT_PROJECTS_FILE"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class RegistryError(Exception):
|
|
35
|
+
"""Ошибки чтения/записи/валидации реестра стендов."""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def bpmkit_config_dir() -> Path:
|
|
39
|
+
"""
|
|
40
|
+
Каталог общих файлов экосистемы BPMkit (реестр стендов, конфиг GUI и т.п.)
|
|
41
|
+
— та же папка, которую резолвит клиентский MCP BPMkit:
|
|
42
|
+
|
|
43
|
+
- Windows: ``%APPDATA%\\BPMkit``;
|
|
44
|
+
- POSIX: ``$XDG_CONFIG_HOME/BPMkit`` либо, если переменная не задана,
|
|
45
|
+
``~/.config/BPMkit``.
|
|
46
|
+
|
|
47
|
+
Функция ничего не создаёт на диске — только считает путь.
|
|
48
|
+
"""
|
|
49
|
+
if sys.platform == "win32":
|
|
50
|
+
appdata = os.environ.get("APPDATA")
|
|
51
|
+
base = Path(appdata) if appdata else Path.home() / "AppData" / "Roaming"
|
|
52
|
+
return base / _BPMKIT_DIR_NAME
|
|
53
|
+
|
|
54
|
+
xdg = os.environ.get("XDG_CONFIG_HOME")
|
|
55
|
+
base = Path(xdg) if xdg else Path.home() / ".config"
|
|
56
|
+
return base / _BPMKIT_DIR_NAME
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def default_registry_path() -> Path:
|
|
60
|
+
"""
|
|
61
|
+
Резолвит путь к реестру стендов ТОЙ ЖЕ цепочкой, что использует BPMkit MCP,
|
|
62
|
+
чтобы standkit и кит смотрели в один и тот же ``projects.json`` без
|
|
63
|
+
дополнительной настройки:
|
|
64
|
+
|
|
65
|
+
1. env ``BPMSOFT_PROJECTS_FILE`` — если задана и путь существует, он в
|
|
66
|
+
приоритете;
|
|
67
|
+
2. канонический путь кита: ``%APPDATA%\\BPMkit\\projects.json`` (Windows)
|
|
68
|
+
или ``$XDG_CONFIG_HOME/BPMkit/projects.json`` / ``~/.config/BPMkit/
|
|
69
|
+
projects.json`` (POSIX);
|
|
70
|
+
3. фолбэк ``./projects.json`` в текущей рабочей директории — для
|
|
71
|
+
standalone-запуска standkit без установленного кита.
|
|
72
|
+
|
|
73
|
+
Возвращает первый СУЩЕСТВУЮЩИЙ файл из цепочки; если ни один не
|
|
74
|
+
существует — возвращает канонический путь п.2 (куда реестр следовало бы
|
|
75
|
+
положить), чтобы вызывающий код мог использовать его как путь для
|
|
76
|
+
первого создания реестра.
|
|
77
|
+
"""
|
|
78
|
+
env_value = os.environ.get(_ENV_REGISTRY_VAR)
|
|
79
|
+
if env_value:
|
|
80
|
+
env_path = Path(env_value)
|
|
81
|
+
if env_path.exists():
|
|
82
|
+
return env_path
|
|
83
|
+
|
|
84
|
+
canonical = bpmkit_config_dir() / _REGISTRY_FILE_NAME
|
|
85
|
+
if canonical.exists():
|
|
86
|
+
return canonical
|
|
87
|
+
|
|
88
|
+
fallback = Path.cwd() / _REGISTRY_FILE_NAME
|
|
89
|
+
if fallback.exists():
|
|
90
|
+
return fallback
|
|
91
|
+
|
|
92
|
+
return canonical
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class Registry:
|
|
96
|
+
"""
|
|
97
|
+
Реестр стендов поверх JSON-файла.
|
|
98
|
+
|
|
99
|
+
Пример:
|
|
100
|
+
reg = Registry.load("projects.json")
|
|
101
|
+
stand = reg.get("my-stand")
|
|
102
|
+
reg.add_existing(Stand(name="another", stand_dir="/opt/bpmsoft/another"))
|
|
103
|
+
reg.save()
|
|
104
|
+
"""
|
|
105
|
+
|
|
106
|
+
def __init__(self, path: Path, default: str = "", stands: Optional[dict[str, Stand]] = None,
|
|
107
|
+
extra_top: Optional[dict] = None):
|
|
108
|
+
self.path = Path(path)
|
|
109
|
+
self.default = default
|
|
110
|
+
self._stands: dict[str, Stand] = stands or {}
|
|
111
|
+
# Прочие top-level ключи исходного файла (у реестра BPMkit: _comment,
|
|
112
|
+
# scaffold_root, shared_docs_root, default_locked и т.п.) — сохраняются,
|
|
113
|
+
# чтобы save() не затирал их и не портил общий с китом projects.json.
|
|
114
|
+
self._extra_top: dict = extra_top or {}
|
|
115
|
+
|
|
116
|
+
# --- чтение/запись ---
|
|
117
|
+
|
|
118
|
+
@classmethod
|
|
119
|
+
def load(cls, path: str | Path) -> "Registry":
|
|
120
|
+
"""
|
|
121
|
+
Читает реестр из файла. Если файла нет — возвращает ПУСТОЙ реестр
|
|
122
|
+
(это нормальная ситуация при первом запуске; см. projects.sample.json
|
|
123
|
+
как образец для ручного заполнения).
|
|
124
|
+
"""
|
|
125
|
+
p = Path(path)
|
|
126
|
+
if not p.exists():
|
|
127
|
+
return cls(path=p, default="", stands={})
|
|
128
|
+
|
|
129
|
+
raw = p.read_text(encoding="utf-8-sig")
|
|
130
|
+
try:
|
|
131
|
+
data = json.loads(raw) if raw.strip() else {}
|
|
132
|
+
except json.JSONDecodeError as exc:
|
|
133
|
+
raise RegistryError(f"Некорректный JSON реестра {p}: {exc}") from exc
|
|
134
|
+
|
|
135
|
+
default = data.get("default", "")
|
|
136
|
+
# Реестр BPMkit (и кита) хранит стенды под ключом "projects"; поддерживаем
|
|
137
|
+
# и "stands" для обратной совместимости со старым форматом standkit.
|
|
138
|
+
stands_raw = data.get("projects")
|
|
139
|
+
if stands_raw is None:
|
|
140
|
+
stands_raw = data.get("stands", {})
|
|
141
|
+
extra_top = {k: v for k, v in data.items() if k not in ("projects", "stands", "default")}
|
|
142
|
+
stands = {name: Stand.from_dict(name, rec) for name, rec in stands_raw.items()}
|
|
143
|
+
return cls(path=p, default=default, stands=stands, extra_top=extra_top)
|
|
144
|
+
|
|
145
|
+
def save(self) -> None:
|
|
146
|
+
"""Пишет реестр обратно в файл БЕЗ BOM, с отступом для читаемости диффов."""
|
|
147
|
+
# Сохраняем В ФОРМАТЕ BPMkit (ключ "projects") и переносим прочие top-level
|
|
148
|
+
# ключи исходного файла — чтобы не разрушить общий с китом реестр.
|
|
149
|
+
payload: dict = dict(self._extra_top)
|
|
150
|
+
payload["default"] = self.default
|
|
151
|
+
payload["projects"] = {name: stand.to_dict() for name, stand in self._stands.items()}
|
|
152
|
+
text = json.dumps(payload, ensure_ascii=False, indent=2)
|
|
153
|
+
self.path.write_text(text, encoding="utf-8")
|
|
154
|
+
|
|
155
|
+
# --- доступ к записям ---
|
|
156
|
+
|
|
157
|
+
def list(self) -> list[Stand]:
|
|
158
|
+
"""Список всех стендов реестра (без гарантии порядка вставки не даётся, но сохраняется)."""
|
|
159
|
+
return list(self._stands.values())
|
|
160
|
+
|
|
161
|
+
def names(self) -> list[str]:
|
|
162
|
+
return list(self._stands.keys())
|
|
163
|
+
|
|
164
|
+
def get(self, name: str) -> Stand:
|
|
165
|
+
"""Возвращает Stand по имени. Бросает RegistryError, если такого нет."""
|
|
166
|
+
try:
|
|
167
|
+
return self._stands[name]
|
|
168
|
+
except KeyError as exc:
|
|
169
|
+
raise RegistryError(f"Стенд '{name}' не найден в реестре {self.path}") from exc
|
|
170
|
+
|
|
171
|
+
def get_default(self) -> Stand:
|
|
172
|
+
"""Возвращает стенд по умолчанию (поле ``default`` реестра)."""
|
|
173
|
+
if not self.default:
|
|
174
|
+
raise RegistryError("В реестре не задан стенд по умолчанию (default)")
|
|
175
|
+
return self.get(self.default)
|
|
176
|
+
|
|
177
|
+
def __contains__(self, name: str) -> bool:
|
|
178
|
+
return name in self._stands
|
|
179
|
+
|
|
180
|
+
def __len__(self) -> int:
|
|
181
|
+
return len(self._stands)
|
|
182
|
+
|
|
183
|
+
# --- изменение реестра ---
|
|
184
|
+
|
|
185
|
+
def add_existing(self, stand: Stand, *, make_default: bool = False) -> None:
|
|
186
|
+
"""
|
|
187
|
+
Привязывает УЖЕ СУЩЕСТВУЮЩИЙ стенд к реестру (не провижининг!).
|
|
188
|
+
|
|
189
|
+
Ожидается, что каталог стенда/БД/дистрибутив уже существуют — этот
|
|
190
|
+
метод только регистрирует их в реестре standkit, чтобы ядро могло
|
|
191
|
+
ими управлять (start/stop/health). Валидирует запись перед добавлением.
|
|
192
|
+
"""
|
|
193
|
+
errors = stand.validate()
|
|
194
|
+
if errors:
|
|
195
|
+
raise RegistryError(
|
|
196
|
+
f"Невозможно добавить стенд '{stand.name}': {'; '.join(errors)}"
|
|
197
|
+
)
|
|
198
|
+
if stand.name in self._stands:
|
|
199
|
+
raise RegistryError(f"Стенд '{stand.name}' уже есть в реестре")
|
|
200
|
+
self._stands[stand.name] = stand
|
|
201
|
+
if make_default or not self.default:
|
|
202
|
+
self.default = stand.name
|
|
203
|
+
|
|
204
|
+
def remove(self, name: str) -> None:
|
|
205
|
+
"""Удаляет запись стенда из реестра (сам стенд физически не трогает)."""
|
|
206
|
+
if name not in self._stands:
|
|
207
|
+
raise RegistryError(f"Стенд '{name}' не найден в реестре")
|
|
208
|
+
del self._stands[name]
|
|
209
|
+
if self.default == name:
|
|
210
|
+
self.default = next(iter(self._stands), "")
|
|
211
|
+
|
|
212
|
+
def update(self, stand: Stand) -> None:
|
|
213
|
+
"""Обновляет существующую запись стенда (по имени)."""
|
|
214
|
+
if stand.name not in self._stands:
|
|
215
|
+
raise RegistryError(f"Стенд '{stand.name}' не найден в реестре, обновлять нечего")
|
|
216
|
+
errors = stand.validate()
|
|
217
|
+
if errors:
|
|
218
|
+
raise RegistryError(f"Невозможно обновить стенд '{stand.name}': {'; '.join(errors)}")
|
|
219
|
+
self._stands[stand.name] = stand
|
|
220
|
+
|
|
221
|
+
def filter_by_transport(self, transport: str) -> Iterable[Stand]:
|
|
222
|
+
"""Вспомогательный фильтр — например, для GUI, чтобы отдельно собрать agent-стенды."""
|
|
223
|
+
return (s for s in self._stands.values() if s.transport.value == transport)
|