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/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