elemctl 0.1.0__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.
- elemctl/__init__.py +25 -0
- elemctl/auth.py +142 -0
- elemctl/build.py +288 -0
- elemctl/cli.py +651 -0
- elemctl/client.py +557 -0
- elemctl/config.py +125 -0
- elemctl/deploy.py +236 -0
- elemctl/errors.py +54 -0
- elemctl/mcp_server.py +210 -0
- elemctl/transport.py +53 -0
- elemctl/versions.py +45 -0
- elemctl-0.1.0.dist-info/METADATA +201 -0
- elemctl-0.1.0.dist-info/RECORD +17 -0
- elemctl-0.1.0.dist-info/WHEEL +5 -0
- elemctl-0.1.0.dist-info/entry_points.txt +2 -0
- elemctl-0.1.0.dist-info/licenses/LICENSE +21 -0
- elemctl-0.1.0.dist-info/top_level.txt +1 -0
elemctl/deploy.py
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
"""Деплой: сборка -> загрузка -> применение -> перезапуск -> проверка.
|
|
2
|
+
|
|
3
|
+
Главная особенность платформы: при ошибке применения она молча откатывает
|
|
4
|
+
приложение на предыдущую сборку и запускает его – статус Running ничего не
|
|
5
|
+
говорит об успехе. Поэтому отчёт деплоя строится по трём проверкам:
|
|
6
|
+
задачи приложения с ошибками после начала деплоя, сверка фактически
|
|
7
|
+
применённой версии и информационный HTTP-запрос по uri приложения.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import tempfile
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from datetime import datetime, timezone
|
|
15
|
+
|
|
16
|
+
from .build import build_assembly
|
|
17
|
+
from .client import extract_assembly_id
|
|
18
|
+
|
|
19
|
+
# Статусы задач приложения, означающие ошибку (сравнение без учёта регистра).
|
|
20
|
+
FAILED_TASK_STATUSES = {"error", "failed"}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class DeployReport:
|
|
25
|
+
"""Отчёт деплоя.
|
|
26
|
+
|
|
27
|
+
applied: True – версия совпала, False – не совпала (похоже на откат),
|
|
28
|
+
None – фактическую версию определить не удалось. ok – итоговый вердикт:
|
|
29
|
+
нет проблем и нет доказанного отката.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
app_id: str = ""
|
|
33
|
+
uri: str = ""
|
|
34
|
+
status: str = ""
|
|
35
|
+
version: str = ""
|
|
36
|
+
assembly_id: str = ""
|
|
37
|
+
applied_version: str = ""
|
|
38
|
+
applied: bool | None = None
|
|
39
|
+
uri_status: int | None = None
|
|
40
|
+
problems: list = field(default_factory=list)
|
|
41
|
+
ok: bool = False
|
|
42
|
+
|
|
43
|
+
def to_dict(self):
|
|
44
|
+
"""Представить отчёт словарём с ключами в kebab-case (для JSON-вывода)."""
|
|
45
|
+
return {
|
|
46
|
+
"app-id": self.app_id,
|
|
47
|
+
"uri": self.uri,
|
|
48
|
+
"status": self.status,
|
|
49
|
+
"version": self.version,
|
|
50
|
+
"assembly-id": self.assembly_id,
|
|
51
|
+
"applied-version": self.applied_version,
|
|
52
|
+
"applied": self.applied,
|
|
53
|
+
"uri-status": self.uri_status,
|
|
54
|
+
"problems": list(self.problems),
|
|
55
|
+
"ok": self.ok,
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def deploy_from_sources(
|
|
60
|
+
client,
|
|
61
|
+
app_id,
|
|
62
|
+
project_id,
|
|
63
|
+
*,
|
|
64
|
+
project_dir=None,
|
|
65
|
+
output_dir=None,
|
|
66
|
+
version="",
|
|
67
|
+
branch=None,
|
|
68
|
+
commit=None,
|
|
69
|
+
commit_message="",
|
|
70
|
+
log=None,
|
|
71
|
+
):
|
|
72
|
+
"""Полный цикл деплоя из исходников с проверкой фактического применения.
|
|
73
|
+
|
|
74
|
+
log – callback для строк прогресса (например print); библиотека сама
|
|
75
|
+
ничего не печатает.
|
|
76
|
+
"""
|
|
77
|
+
log = log or (lambda message: None)
|
|
78
|
+
started_at = datetime.now(timezone.utc)
|
|
79
|
+
|
|
80
|
+
# Версия сборки: явная либо автоинкремент от последней сборки проекта.
|
|
81
|
+
last_version = ""
|
|
82
|
+
if not version:
|
|
83
|
+
latest = client.latest_assembly(project_id)
|
|
84
|
+
if latest:
|
|
85
|
+
last_version = str(latest.get("assembly-version") or "")
|
|
86
|
+
|
|
87
|
+
result = build_assembly(
|
|
88
|
+
project_dir,
|
|
89
|
+
output_dir=output_dir or tempfile.mkdtemp(prefix="elemctl-build-"),
|
|
90
|
+
version=version,
|
|
91
|
+
last_build_version=last_version,
|
|
92
|
+
branch=branch,
|
|
93
|
+
commit=commit,
|
|
94
|
+
)
|
|
95
|
+
log(f"собран архив {result.file} (версия {result.version})")
|
|
96
|
+
|
|
97
|
+
response = client.upload_assembly(
|
|
98
|
+
result.file.read_bytes(),
|
|
99
|
+
project_id=project_id,
|
|
100
|
+
branch_name=result.branch or None,
|
|
101
|
+
commit_id=result.commit or None,
|
|
102
|
+
commit_message=commit_message or None,
|
|
103
|
+
)
|
|
104
|
+
assembly_id = extract_assembly_id(response) or ""
|
|
105
|
+
log(f"сборка загружена (id: {assembly_id or 'не определён'})")
|
|
106
|
+
|
|
107
|
+
if assembly_id:
|
|
108
|
+
client.apply_build(app_id, image_id=assembly_id)
|
|
109
|
+
else:
|
|
110
|
+
# Ответ без id сборки: применяем по проекту и версии.
|
|
111
|
+
client.apply_build(app_id, project_id=project_id, assembly_version=result.version)
|
|
112
|
+
log("применение запущено, ждём стабилизации приложения...")
|
|
113
|
+
|
|
114
|
+
card = client.ensure_running(app_id, log=log)
|
|
115
|
+
log("приложение в статусе Running, проверяем фактическое применение...")
|
|
116
|
+
|
|
117
|
+
report = _verify(
|
|
118
|
+
client,
|
|
119
|
+
app_id,
|
|
120
|
+
card=card,
|
|
121
|
+
expected_version=result.version,
|
|
122
|
+
since=started_at,
|
|
123
|
+
)
|
|
124
|
+
report.assembly_id = assembly_id
|
|
125
|
+
_log_outcome(report, log)
|
|
126
|
+
return report
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def deploy_from_platform_branch(client, app_id, branch_id, *, log=None):
|
|
130
|
+
"""Деплой загрузкой изменений из git-ветки платформы (внутренний UI API).
|
|
131
|
+
|
|
132
|
+
Проверка применения та же, но без сверки версии (её у загрузки из
|
|
133
|
+
ветки нет), поэтому applied остаётся None.
|
|
134
|
+
"""
|
|
135
|
+
log = log or (lambda message: None)
|
|
136
|
+
started_at = datetime.now(timezone.utc)
|
|
137
|
+
|
|
138
|
+
log(f"загружаем изменения из ветки {branch_id} в приложение {app_id}...")
|
|
139
|
+
client.sync_branch(app_id, branch_id)
|
|
140
|
+
log("загрузка запущена, ждём стабилизации приложения...")
|
|
141
|
+
|
|
142
|
+
card = client.ensure_running(app_id, log=log)
|
|
143
|
+
log("приложение в статусе Running, проверяем задачи приложения...")
|
|
144
|
+
|
|
145
|
+
report = _verify(client, app_id, card=card, expected_version="", since=started_at)
|
|
146
|
+
_log_outcome(report, log)
|
|
147
|
+
return report
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def verify_deploy(client, app_id, *, expected_version="", since=None, log=None):
|
|
151
|
+
"""Самостоятельная проверка применения (без деплоя).
|
|
152
|
+
|
|
153
|
+
since – момент, раньше которого ошибки задач не учитываются (старые
|
|
154
|
+
ошибки из истории не должны портить вердикт).
|
|
155
|
+
"""
|
|
156
|
+
log = log or (lambda message: None)
|
|
157
|
+
report = _verify(client, app_id, card=None, expected_version=expected_version, since=since)
|
|
158
|
+
_log_outcome(report, log)
|
|
159
|
+
return report
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
# -- внутреннее ---------------------------------------------------------------
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _verify(client, app_id, *, card, expected_version, since):
|
|
166
|
+
problems = []
|
|
167
|
+
|
|
168
|
+
# 1. Задачи приложения со статусами Error/Failed после начала деплоя.
|
|
169
|
+
for task in client.list_app_tasks(app_id):
|
|
170
|
+
if not isinstance(task, dict):
|
|
171
|
+
continue
|
|
172
|
+
status = str(task.get("status") or "")
|
|
173
|
+
if status.lower() not in FAILED_TASK_STATUSES:
|
|
174
|
+
continue
|
|
175
|
+
task_started = _parse_datetime(task.get("start-date"))
|
|
176
|
+
if since is not None and task_started is not None and task_started < since:
|
|
177
|
+
continue
|
|
178
|
+
label = task.get("operation-type") or task.get("id") or "задача"
|
|
179
|
+
message = task.get("error-message") or "без текста ошибки"
|
|
180
|
+
problems.append(f"задача {label} завершилась со статусом {status}: {message}")
|
|
181
|
+
|
|
182
|
+
# 2. Сверка фактически применённой версии с загруженной.
|
|
183
|
+
if card is None:
|
|
184
|
+
card = client.get_app(app_id) or {}
|
|
185
|
+
source = card.get("source") or {}
|
|
186
|
+
applied_version = str(source.get("project-version") or "")
|
|
187
|
+
applied = None
|
|
188
|
+
if expected_version and applied_version:
|
|
189
|
+
applied = applied_version == expected_version
|
|
190
|
+
if not applied:
|
|
191
|
+
problems.append(
|
|
192
|
+
f"применённая версия {applied_version} не совпадает с загруженной "
|
|
193
|
+
f"{expected_version} – похоже, платформа откатила применение"
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
# 3. Информационный GET по uri приложения (401/403 нормальны).
|
|
197
|
+
uri = str(card.get("uri") or "")
|
|
198
|
+
uri_status = client.check_uri(uri) if uri else None
|
|
199
|
+
|
|
200
|
+
report = DeployReport(
|
|
201
|
+
app_id=str(app_id),
|
|
202
|
+
uri=uri,
|
|
203
|
+
status=str(card.get("status") or ""),
|
|
204
|
+
version=expected_version or "",
|
|
205
|
+
applied_version=applied_version,
|
|
206
|
+
applied=applied,
|
|
207
|
+
uri_status=uri_status,
|
|
208
|
+
problems=problems,
|
|
209
|
+
)
|
|
210
|
+
report.ok = not problems and applied is not False
|
|
211
|
+
return report
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _log_outcome(report, log):
|
|
215
|
+
if report.ok:
|
|
216
|
+
log("проверка пройдена: сборка применена")
|
|
217
|
+
else:
|
|
218
|
+
for problem in report.problems:
|
|
219
|
+
log(f"проблема: {problem}")
|
|
220
|
+
log("проверка НЕ пройдена")
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _parse_datetime(value):
|
|
224
|
+
"""Разобрать дату ISO 8601 (допускается суффикс Z); наивную считать UTC."""
|
|
225
|
+
if not value or not isinstance(value, str):
|
|
226
|
+
return None
|
|
227
|
+
text = value.strip()
|
|
228
|
+
if text.endswith("Z"):
|
|
229
|
+
text = text[:-1] + "+00:00"
|
|
230
|
+
try:
|
|
231
|
+
parsed = datetime.fromisoformat(text)
|
|
232
|
+
except ValueError:
|
|
233
|
+
return None
|
|
234
|
+
if parsed.tzinfo is None:
|
|
235
|
+
parsed = parsed.replace(tzinfo=timezone.utc)
|
|
236
|
+
return parsed
|
elemctl/errors.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Исключения elemctl.
|
|
2
|
+
|
|
3
|
+
Иерархия простая: всё наследуется от ElemctlError, чтобы вызывающая сторона
|
|
4
|
+
могла перехватить любую ошибку инструмента одним except.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ElemctlError(Exception):
|
|
11
|
+
"""Базовое исключение elemctl."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ConfigError(ElemctlError):
|
|
15
|
+
"""Ошибка конфигурации подключения: не хватает параметров или файла."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class BuildError(ElemctlError):
|
|
19
|
+
"""Ошибка локальной сборки архива .xasm/.xlib."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class TransportError(ElemctlError):
|
|
23
|
+
"""Сетевая ошибка: до сервера не удалось достучаться."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ApiError(ElemctlError):
|
|
27
|
+
"""Ошибка Console API: HTTP-статус и тело ответа сервера.
|
|
28
|
+
|
|
29
|
+
Детали сериализуемы в JSON (метод to_dict) – так CLI выводит их
|
|
30
|
+
в stderr без потери информации.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def __init__(self, message, *, status=None, method=None, url=None, body=None, hint=None):
|
|
34
|
+
super().__init__(message)
|
|
35
|
+
self.message = message
|
|
36
|
+
self.status = status
|
|
37
|
+
self.method = method
|
|
38
|
+
self.url = url
|
|
39
|
+
self.body = body
|
|
40
|
+
self.hint = hint
|
|
41
|
+
|
|
42
|
+
def to_dict(self):
|
|
43
|
+
"""Представить ошибку JSON-сериализуемым словарём."""
|
|
44
|
+
payload = {"error": self.message}
|
|
45
|
+
for key, value in (
|
|
46
|
+
("status", self.status),
|
|
47
|
+
("method", self.method),
|
|
48
|
+
("url", self.url),
|
|
49
|
+
("body", self.body),
|
|
50
|
+
("hint", self.hint),
|
|
51
|
+
):
|
|
52
|
+
if value not in (None, ""):
|
|
53
|
+
payload[key] = value
|
|
54
|
+
return payload
|
elemctl/mcp_server.py
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
"""MCP-сервер elemctl: операции платформы как инструменты для AI-агентов.
|
|
2
|
+
|
|
3
|
+
Транспорт – stdio; реквизиты подключения – из переменных окружения
|
|
4
|
+
ELEMENT_* или .env-файла в текущем каталоге. Требует optional extra
|
|
5
|
+
"elemctl[mcp]" (пакет mcp>=1.2, используется FastMCP).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from datetime import datetime, timedelta, timezone
|
|
11
|
+
|
|
12
|
+
try:
|
|
13
|
+
from mcp.server.fastmcp import FastMCP
|
|
14
|
+
except ImportError as error: # pragma: no cover - ветка без extra
|
|
15
|
+
raise ImportError(
|
|
16
|
+
'для MCP-сервера нужен extra: pip install "elemctl[mcp]"'
|
|
17
|
+
) from error
|
|
18
|
+
|
|
19
|
+
from .build import build_assembly
|
|
20
|
+
from .client import ElementClient, extract_assembly_id
|
|
21
|
+
from .config import Config
|
|
22
|
+
from .deploy import (
|
|
23
|
+
deploy_from_platform_branch,
|
|
24
|
+
deploy_from_sources,
|
|
25
|
+
verify_deploy as _verify_deploy,
|
|
26
|
+
)
|
|
27
|
+
from .errors import ElemctlError
|
|
28
|
+
|
|
29
|
+
INSTRUCTIONS = (
|
|
30
|
+
"Инструменты управления платформой 1С:Предприятие.Элемент (Console API v2). "
|
|
31
|
+
"Важно: при ошибке применения сборки платформа МОЛЧА откатывает приложение "
|
|
32
|
+
"на предыдущую сборку и запускает его – статус Running не означает успех "
|
|
33
|
+
"деплоя. Доверяйте только отчёту инструментов deploy/verify_deploy: поле ok, "
|
|
34
|
+
"список problems и сверка applied-version с загруженной версией."
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def create_server(config=None):
|
|
39
|
+
"""Создать MCP-сервер elemctl со всеми инструментами.
|
|
40
|
+
|
|
41
|
+
config – готовая конфигурация; без неё она собирается из переменных
|
|
42
|
+
окружения и .env при первом обращении к платформе.
|
|
43
|
+
"""
|
|
44
|
+
server = FastMCP("elemctl", instructions=INSTRUCTIONS)
|
|
45
|
+
state = {"client": None, "config": config}
|
|
46
|
+
|
|
47
|
+
def client():
|
|
48
|
+
if state["client"] is None:
|
|
49
|
+
cfg = state["config"] or Config.from_env()
|
|
50
|
+
state["client"] = ElementClient(cfg)
|
|
51
|
+
return state["client"]
|
|
52
|
+
|
|
53
|
+
@server.tool()
|
|
54
|
+
def list_apps(name: str = "") -> list:
|
|
55
|
+
"""Список приложений платформы; name – необязательный фильтр по имени."""
|
|
56
|
+
return client().list_apps(name=name)
|
|
57
|
+
|
|
58
|
+
@server.tool()
|
|
59
|
+
def get_app(app_id: str) -> dict:
|
|
60
|
+
"""Карточка приложения: статус, uri, фактическая версия проекта (source.project-version)."""
|
|
61
|
+
return client().get_app(app_id)
|
|
62
|
+
|
|
63
|
+
@server.tool()
|
|
64
|
+
def find_app(name: str) -> dict:
|
|
65
|
+
"""Найти приложение по точному имени без учёта регистра; вернуть id и признак found."""
|
|
66
|
+
app = client().find_app(name)
|
|
67
|
+
if app is None:
|
|
68
|
+
return {"id": None, "found": False}
|
|
69
|
+
return {"id": app.get("id"), "found": True, "application": app}
|
|
70
|
+
|
|
71
|
+
@server.tool()
|
|
72
|
+
def create_app(
|
|
73
|
+
name: str,
|
|
74
|
+
project_id: str = "",
|
|
75
|
+
version_id: str = "",
|
|
76
|
+
space_id: str = "",
|
|
77
|
+
development_mode: bool = True,
|
|
78
|
+
) -> dict:
|
|
79
|
+
"""Создать приложение. При задании только project_id источником берётся последняя сборка проекта (создание из проекта целиком может дать пустой каркас)."""
|
|
80
|
+
source_version_id = version_id
|
|
81
|
+
if not source_version_id:
|
|
82
|
+
if not project_id:
|
|
83
|
+
raise ElemctlError("нужен project_id или version_id")
|
|
84
|
+
latest = client().latest_assembly(project_id)
|
|
85
|
+
if latest is None:
|
|
86
|
+
raise ElemctlError(
|
|
87
|
+
f"у проекта {project_id} нет сборок – загрузите сборку или укажите version_id"
|
|
88
|
+
)
|
|
89
|
+
source_version_id = extract_assembly_id(latest)
|
|
90
|
+
return client().create_app(
|
|
91
|
+
name,
|
|
92
|
+
project_version_id=source_version_id,
|
|
93
|
+
development_mode=development_mode,
|
|
94
|
+
space_id=space_id or None,
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
@server.tool()
|
|
98
|
+
def start_app(app_id: str) -> dict:
|
|
99
|
+
"""Запустить приложение."""
|
|
100
|
+
return client().start_app(app_id) or {"ok": True, "app-id": app_id}
|
|
101
|
+
|
|
102
|
+
@server.tool()
|
|
103
|
+
def stop_app(app_id: str) -> dict:
|
|
104
|
+
"""Остановить приложение."""
|
|
105
|
+
return client().stop_app(app_id) or {"ok": True, "app-id": app_id}
|
|
106
|
+
|
|
107
|
+
@server.tool()
|
|
108
|
+
def delete_app(app_id: str) -> dict:
|
|
109
|
+
"""Удалить приложение. НЕОБРАТИМО: данные теряются, а пересозданное приложение получит другой URL – внешние настройки (OIDC redirect и т.п.) придётся обновлять."""
|
|
110
|
+
return client().delete_app(app_id) or {"deleted": True, "app-id": app_id}
|
|
111
|
+
|
|
112
|
+
@server.tool()
|
|
113
|
+
def list_spaces() -> list:
|
|
114
|
+
"""Список пространств."""
|
|
115
|
+
return client().list_spaces()
|
|
116
|
+
|
|
117
|
+
@server.tool()
|
|
118
|
+
def list_projects() -> list:
|
|
119
|
+
"""Список проектов."""
|
|
120
|
+
return client().list_projects()
|
|
121
|
+
|
|
122
|
+
@server.tool()
|
|
123
|
+
def list_builds(project_id: str) -> list:
|
|
124
|
+
"""Список сборок проекта."""
|
|
125
|
+
return client().list_assemblies(project_id)
|
|
126
|
+
|
|
127
|
+
# Имя функции отличается от имени инструмента, чтобы не затенять
|
|
128
|
+
# импортированный build_assembly из модуля build.
|
|
129
|
+
@server.tool(name="build_assembly")
|
|
130
|
+
def build_assembly_tool(project_dir: str = "", output_dir: str = "", version: str = "") -> dict:
|
|
131
|
+
"""Локально собрать архив .xasm/.xlib из исходников проекта."""
|
|
132
|
+
result = build_assembly(
|
|
133
|
+
project_dir or None, output_dir=output_dir or None, version=version
|
|
134
|
+
)
|
|
135
|
+
return {"file": str(result.file), "version": result.version, "kind": result.kind}
|
|
136
|
+
|
|
137
|
+
@server.tool()
|
|
138
|
+
def deploy(
|
|
139
|
+
app_id: str,
|
|
140
|
+
project_id: str,
|
|
141
|
+
project_dir: str = "",
|
|
142
|
+
version: str = "",
|
|
143
|
+
branch: str = "",
|
|
144
|
+
commit_message: str = "",
|
|
145
|
+
) -> dict:
|
|
146
|
+
"""Полный цикл деплоя из исходников с честной проверкой применения; итог – поле ok, детали – problems и log."""
|
|
147
|
+
lines: list[str] = []
|
|
148
|
+
report = deploy_from_sources(
|
|
149
|
+
client(),
|
|
150
|
+
app_id,
|
|
151
|
+
project_id,
|
|
152
|
+
project_dir=project_dir or None,
|
|
153
|
+
version=version,
|
|
154
|
+
branch=branch or None,
|
|
155
|
+
commit_message=commit_message,
|
|
156
|
+
log=lines.append,
|
|
157
|
+
)
|
|
158
|
+
payload = report.to_dict()
|
|
159
|
+
payload["log"] = lines
|
|
160
|
+
return payload
|
|
161
|
+
|
|
162
|
+
@server.tool(name="deploy_from_platform_branch")
|
|
163
|
+
def deploy_from_platform_branch_tool(app_id: str, branch_id: str) -> dict:
|
|
164
|
+
"""Загрузить изменения из git-ветки платформы в приложение и проверить итог (без сверки версии)."""
|
|
165
|
+
lines: list[str] = []
|
|
166
|
+
report = deploy_from_platform_branch(client(), app_id, branch_id, log=lines.append)
|
|
167
|
+
payload = report.to_dict()
|
|
168
|
+
payload["log"] = lines
|
|
169
|
+
return payload
|
|
170
|
+
|
|
171
|
+
@server.tool()
|
|
172
|
+
def apply_build(app_id: str, version_id: str) -> dict:
|
|
173
|
+
"""Применить загруженную сборку (по id) к приложению; после применения проверьте итог инструментом verify_deploy."""
|
|
174
|
+
response = client().apply_build(app_id, image_id=version_id)
|
|
175
|
+
return response or {"ok": True, "app-id": app_id, "version-id": version_id}
|
|
176
|
+
|
|
177
|
+
@server.tool()
|
|
178
|
+
def verify_deploy(app_id: str, expected_version: str = "", since_minutes: int = 30) -> dict:
|
|
179
|
+
"""Проверить фактическое применение сборки: задачи с ошибками за последние since_minutes минут, сверка версии, доступность uri."""
|
|
180
|
+
since = datetime.now(timezone.utc) - timedelta(minutes=since_minutes)
|
|
181
|
+
report = _verify_deploy(
|
|
182
|
+
client(), app_id, expected_version=expected_version, since=since
|
|
183
|
+
)
|
|
184
|
+
return report.to_dict()
|
|
185
|
+
|
|
186
|
+
@server.tool()
|
|
187
|
+
def list_app_tasks(app_id: str = "") -> list:
|
|
188
|
+
"""Задачи приложений; app_id – необязательный фильтр (выполняется на клиенте)."""
|
|
189
|
+
return client().list_app_tasks(app_id)
|
|
190
|
+
|
|
191
|
+
@server.tool()
|
|
192
|
+
def list_branches(project_id: str = "", name: str = "") -> list:
|
|
193
|
+
"""Список веток среды разработки; фильтры project_id и name необязательны."""
|
|
194
|
+
return client().list_branches(project_id=project_id, name=name)
|
|
195
|
+
|
|
196
|
+
@server.tool()
|
|
197
|
+
def merge_branch(branch_id: str) -> dict:
|
|
198
|
+
"""Принять изменения ветки среды разработки (merge)."""
|
|
199
|
+
return client().merge_branch(branch_id) or {"merged": True, "branch-id": branch_id}
|
|
200
|
+
|
|
201
|
+
return server
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def main():
|
|
205
|
+
"""Запустить MCP-сервер на stdio."""
|
|
206
|
+
create_server().run()
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
if __name__ == "__main__":
|
|
210
|
+
main()
|
elemctl/transport.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""HTTP-транспорт поверх urllib – единственная точка сетевых вызовов.
|
|
2
|
+
|
|
3
|
+
Транспорт выделен в отдельный объект, чтобы тесты могли подменить его
|
|
4
|
+
заглушкой и работать без сети.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import urllib.error
|
|
11
|
+
import urllib.request
|
|
12
|
+
|
|
13
|
+
from .errors import TransportError
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class HttpResponse:
|
|
17
|
+
"""Ответ HTTP-запроса: статус, заголовки и тело в байтах."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, status, headers, body):
|
|
20
|
+
self.status = int(status)
|
|
21
|
+
self.headers = dict(headers or {})
|
|
22
|
+
self.body = body or b""
|
|
23
|
+
|
|
24
|
+
def text(self):
|
|
25
|
+
"""Тело ответа строкой (UTF-8, нечитаемые байты заменяются)."""
|
|
26
|
+
return self.body.decode("utf-8", errors="replace")
|
|
27
|
+
|
|
28
|
+
def json(self):
|
|
29
|
+
"""Тело ответа как JSON; для пустого тела – None."""
|
|
30
|
+
if not self.body:
|
|
31
|
+
return None
|
|
32
|
+
return json.loads(self.text())
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class UrllibTransport:
|
|
36
|
+
"""Транспорт на urllib.request.
|
|
37
|
+
|
|
38
|
+
Ответы с кодами не-2xx возвращаются как обычные ответы (решение об
|
|
39
|
+
ошибке принимает клиент); сетевые сбои превращаются в TransportError.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
def request(self, method, url, *, headers=None, data=None, timeout=60.0):
|
|
43
|
+
request = urllib.request.Request(url, data=data, method=method)
|
|
44
|
+
for name, value in (headers or {}).items():
|
|
45
|
+
request.add_header(name, value)
|
|
46
|
+
try:
|
|
47
|
+
with urllib.request.urlopen(request, timeout=timeout) as response:
|
|
48
|
+
return HttpResponse(response.status, response.headers, response.read())
|
|
49
|
+
except urllib.error.HTTPError as error:
|
|
50
|
+
# HTTPError сам является ответом – возвращаем его тело и код.
|
|
51
|
+
return HttpResponse(error.code, error.headers, error.read())
|
|
52
|
+
except OSError as error:
|
|
53
|
+
raise TransportError(f"сетевая ошибка {method} {url}: {error}") from error
|
elemctl/versions.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Версии сборок: числовое сравнение и автоинкремент.
|
|
2
|
+
|
|
3
|
+
Версия сборки имеет вид "{база}-{счётчик}", например "1.0-42". Сравнивать
|
|
4
|
+
версии нужно по числовому счётчику после последнего дефиса: "1.0-10" новее
|
|
5
|
+
"1.0-9", хотя лексикографически порядок обратный.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def version_counter(version):
|
|
12
|
+
"""Числовой счётчик версии – суффикс после последнего дефиса.
|
|
13
|
+
|
|
14
|
+
Для версии без дефиса или с нечисловым суффиксом возвращается 0.
|
|
15
|
+
"""
|
|
16
|
+
text = str(version or "")
|
|
17
|
+
head, sep, tail = text.rpartition("-")
|
|
18
|
+
if not sep:
|
|
19
|
+
return 0
|
|
20
|
+
try:
|
|
21
|
+
return int(tail)
|
|
22
|
+
except ValueError:
|
|
23
|
+
return 0
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def next_version(base_version, last_version=None):
|
|
27
|
+
"""Следующая версия сборки: "{база}-{N+1}" от последней, иначе "{база}-1"."""
|
|
28
|
+
base = (base_version or "1.0").strip()
|
|
29
|
+
if not last_version:
|
|
30
|
+
return f"{base}-1"
|
|
31
|
+
return f"{base}-{version_counter(last_version) + 1}"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def pick_latest(assemblies, version_key="assembly-version"):
|
|
35
|
+
"""Выбрать из списка сборок последнюю по числовому счётчику версии."""
|
|
36
|
+
best = None
|
|
37
|
+
best_counter = -1
|
|
38
|
+
for item in assemblies or []:
|
|
39
|
+
if not isinstance(item, dict):
|
|
40
|
+
continue
|
|
41
|
+
counter = version_counter(item.get(version_key))
|
|
42
|
+
if counter > best_counter:
|
|
43
|
+
best = item
|
|
44
|
+
best_counter = counter
|
|
45
|
+
return best
|