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/client.py
ADDED
|
@@ -0,0 +1,557 @@
|
|
|
1
|
+
"""Клиент Console API v2 платформы 1С:Предприятие.Элемент.
|
|
2
|
+
|
|
3
|
+
Клиент не печатает ничего сам: прогресс длительных операций отдаётся через
|
|
4
|
+
callback log, который передаёт вызывающая сторона.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import time
|
|
11
|
+
from urllib.parse import urlencode
|
|
12
|
+
|
|
13
|
+
from .auth import TokenManager
|
|
14
|
+
from .errors import ApiError, ConfigError
|
|
15
|
+
from .transport import UrllibTransport
|
|
16
|
+
from .versions import pick_latest
|
|
17
|
+
|
|
18
|
+
API_PREFIX = "/console/api/v2"
|
|
19
|
+
|
|
20
|
+
# Стабильные статусы приложения; всё прочее (в т.ч. пустая строка) – переходное.
|
|
21
|
+
STABLE_STATUSES = {"Running", "Stopped", "Error"}
|
|
22
|
+
|
|
23
|
+
# Таймауты ожиданий (секунды) по разделу 6 спецификации.
|
|
24
|
+
POLL_INTERVAL = 10.0
|
|
25
|
+
STOP_TIMEOUT = 180.0
|
|
26
|
+
START_TIMEOUT = 300.0
|
|
27
|
+
READY_TIMEOUT = 600.0
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def extract_assembly_id(payload):
|
|
31
|
+
"""Достать id сборки из ответа платформы.
|
|
32
|
+
|
|
33
|
+
Проверяются поля image-id, assembly-id и id – именно в этом порядке.
|
|
34
|
+
"""
|
|
35
|
+
if not isinstance(payload, dict):
|
|
36
|
+
return None
|
|
37
|
+
for key in ("image-id", "assembly-id", "id"):
|
|
38
|
+
value = payload.get(key)
|
|
39
|
+
if value:
|
|
40
|
+
return value
|
|
41
|
+
return None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _as_list(payload, *keys):
|
|
45
|
+
"""Привести ответ-список к списку.
|
|
46
|
+
|
|
47
|
+
Платформа может вернуть как массив, так и объект со списком в одном из
|
|
48
|
+
полей (например items или assemblies).
|
|
49
|
+
"""
|
|
50
|
+
if payload is None:
|
|
51
|
+
return []
|
|
52
|
+
if isinstance(payload, list):
|
|
53
|
+
return payload
|
|
54
|
+
if isinstance(payload, dict):
|
|
55
|
+
for key in keys:
|
|
56
|
+
value = payload.get(key)
|
|
57
|
+
if isinstance(value, list):
|
|
58
|
+
return value
|
|
59
|
+
return []
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _collapse_reference(value):
|
|
63
|
+
"""Свернуть ссылочный объект до {"id": ...} или {"name": ...}."""
|
|
64
|
+
if not isinstance(value, dict):
|
|
65
|
+
return None
|
|
66
|
+
if value.get("id"):
|
|
67
|
+
return {"id": value["id"]}
|
|
68
|
+
if value.get("name"):
|
|
69
|
+
return {"name": value["name"]}
|
|
70
|
+
return None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class ElementClient:
|
|
74
|
+
"""Программный клиент Console API v2."""
|
|
75
|
+
|
|
76
|
+
def __init__(self, config, transport=None, token_cache_dir=None):
|
|
77
|
+
self.config = config
|
|
78
|
+
self._transport = transport or UrllibTransport()
|
|
79
|
+
self._tokens = TokenManager(config, self._transport, cache_dir=token_cache_dir)
|
|
80
|
+
# Точка подмены для тестов: ожидания не должны реально спать.
|
|
81
|
+
self._sleep = time.sleep
|
|
82
|
+
|
|
83
|
+
# -- низкий уровень --------------------------------------------------
|
|
84
|
+
|
|
85
|
+
def token(self):
|
|
86
|
+
"""Получить действующий Bearer-токен."""
|
|
87
|
+
return self._tokens.get_token()
|
|
88
|
+
|
|
89
|
+
def _request(self, method, path, *, query=None, json_body=None, data=None, content_type=None):
|
|
90
|
+
"""Выполнить запрос с Bearer-токеном; при 401 обновить токен и повторить один раз."""
|
|
91
|
+
config = self.config.require()
|
|
92
|
+
url = config.base_url + path
|
|
93
|
+
if query:
|
|
94
|
+
filtered = {k: v for k, v in query.items() if v not in (None, "")}
|
|
95
|
+
if filtered:
|
|
96
|
+
url += "?" + urlencode(filtered)
|
|
97
|
+
|
|
98
|
+
body = data
|
|
99
|
+
headers = {}
|
|
100
|
+
if json_body is not None:
|
|
101
|
+
body = json.dumps(json_body, ensure_ascii=False).encode("utf-8")
|
|
102
|
+
headers["Content-Type"] = "application/json"
|
|
103
|
+
elif content_type:
|
|
104
|
+
headers["Content-Type"] = content_type
|
|
105
|
+
|
|
106
|
+
token = self._tokens.get_token()
|
|
107
|
+
response = None
|
|
108
|
+
for attempt in (1, 2):
|
|
109
|
+
headers["Authorization"] = f"Bearer {token}"
|
|
110
|
+
response = self._transport.request(
|
|
111
|
+
method, url, headers=headers, data=body, timeout=config.timeout
|
|
112
|
+
)
|
|
113
|
+
if response.status == 401 and attempt == 1:
|
|
114
|
+
token = self._tokens.get_token(force=True)
|
|
115
|
+
continue
|
|
116
|
+
break
|
|
117
|
+
|
|
118
|
+
if 200 <= response.status < 300:
|
|
119
|
+
if not response.body:
|
|
120
|
+
return None
|
|
121
|
+
try:
|
|
122
|
+
return response.json()
|
|
123
|
+
except ValueError:
|
|
124
|
+
return response.text()
|
|
125
|
+
raise self._api_error(method, url, response)
|
|
126
|
+
|
|
127
|
+
def _api(self, method, path, **kwargs):
|
|
128
|
+
"""Запрос к Console API v2 (общий префикс /console/api/v2)."""
|
|
129
|
+
return self._request(method, API_PREFIX + path, **kwargs)
|
|
130
|
+
|
|
131
|
+
@staticmethod
|
|
132
|
+
def _api_error(method, url, response):
|
|
133
|
+
try:
|
|
134
|
+
body = response.json()
|
|
135
|
+
except ValueError:
|
|
136
|
+
body = response.text()
|
|
137
|
+
message = f"Console API ответил {response.status} на {method} {url}"
|
|
138
|
+
if isinstance(body, dict):
|
|
139
|
+
for key in ("message", "error", "detail"):
|
|
140
|
+
detail = body.get(key)
|
|
141
|
+
if isinstance(detail, str) and detail.strip():
|
|
142
|
+
message += f": {detail.strip()}"
|
|
143
|
+
break
|
|
144
|
+
return ApiError(message, status=response.status, method=method, url=url, body=body)
|
|
145
|
+
|
|
146
|
+
def check_uri(self, uri, timeout=15.0):
|
|
147
|
+
"""Контрольный GET по адресу приложения; вернуть HTTP-статус или None.
|
|
148
|
+
|
|
149
|
+
Проверка информационная: 401/403 нормальны для закрытых приложений.
|
|
150
|
+
"""
|
|
151
|
+
if not uri:
|
|
152
|
+
return None
|
|
153
|
+
try:
|
|
154
|
+
response = self._transport.request("GET", uri, timeout=timeout)
|
|
155
|
+
return response.status
|
|
156
|
+
except Exception:
|
|
157
|
+
return None
|
|
158
|
+
|
|
159
|
+
# -- приложения --------------------------------------------------------
|
|
160
|
+
|
|
161
|
+
def list_apps(self, name=""):
|
|
162
|
+
"""Список приложений; name – необязательный фильтр платформы."""
|
|
163
|
+
payload = self._api("GET", "/applications", query={"name": name})
|
|
164
|
+
return _as_list(payload, "items", "applications")
|
|
165
|
+
|
|
166
|
+
def get_app(self, app_id):
|
|
167
|
+
"""Карточка приложения (статус, uri, source.project-version и др.)."""
|
|
168
|
+
return self._api("GET", f"/applications/{app_id}")
|
|
169
|
+
|
|
170
|
+
def find_app(self, name):
|
|
171
|
+
"""Найти приложение по точному совпадению имени без учёта регистра.
|
|
172
|
+
|
|
173
|
+
Имя сверяется с полями name, display-name и publication-context.
|
|
174
|
+
Возвращается карточка из списка либо None.
|
|
175
|
+
"""
|
|
176
|
+
target = (name or "").strip().lower()
|
|
177
|
+
if not target:
|
|
178
|
+
return None
|
|
179
|
+
for app in self.list_apps():
|
|
180
|
+
if not isinstance(app, dict):
|
|
181
|
+
continue
|
|
182
|
+
for key in ("name", "display-name", "publication-context"):
|
|
183
|
+
value = app.get(key)
|
|
184
|
+
if isinstance(value, str) and value.strip().lower() == target:
|
|
185
|
+
return app
|
|
186
|
+
return None
|
|
187
|
+
|
|
188
|
+
def create_app(
|
|
189
|
+
self,
|
|
190
|
+
display_name,
|
|
191
|
+
*,
|
|
192
|
+
publication_context=None,
|
|
193
|
+
project_version_id=None,
|
|
194
|
+
image_id=None,
|
|
195
|
+
development_mode=True,
|
|
196
|
+
space_id=None,
|
|
197
|
+
technology_version=None,
|
|
198
|
+
):
|
|
199
|
+
"""Создать приложение.
|
|
200
|
+
|
|
201
|
+
Источник – ровно один из project_version_id (id сборки, надёжный
|
|
202
|
+
путь) либо image_id (id проекта; на части конфигураций платформы
|
|
203
|
+
даёт пустой каркас без данных).
|
|
204
|
+
"""
|
|
205
|
+
if bool(project_version_id) == bool(image_id):
|
|
206
|
+
raise ConfigError(
|
|
207
|
+
"источник приложения – ровно один из параметров: project_version_id либо image_id"
|
|
208
|
+
)
|
|
209
|
+
source = {"type": "repository"}
|
|
210
|
+
if project_version_id:
|
|
211
|
+
source["project-version-id"] = project_version_id
|
|
212
|
+
else:
|
|
213
|
+
source["image-id"] = image_id
|
|
214
|
+
body = {
|
|
215
|
+
"source": source,
|
|
216
|
+
"display-name": display_name,
|
|
217
|
+
"publication-context": publication_context or display_name,
|
|
218
|
+
"development-mode": bool(development_mode),
|
|
219
|
+
}
|
|
220
|
+
if space_id:
|
|
221
|
+
body["space-id"] = space_id
|
|
222
|
+
if technology_version:
|
|
223
|
+
body["technology-version"] = technology_version
|
|
224
|
+
return self._api("POST", "/applications", json_body=body)
|
|
225
|
+
|
|
226
|
+
def delete_app(self, app_id):
|
|
227
|
+
"""Удалить приложение.
|
|
228
|
+
|
|
229
|
+
Необратимо: пересозданное приложение получает другой URL. Если в
|
|
230
|
+
среде разработки есть неопубликованные правки, платформа отвечает
|
|
231
|
+
400 FAILED_PRECONDITION – в этом случае к ошибке добавляется
|
|
232
|
+
подсказка.
|
|
233
|
+
"""
|
|
234
|
+
try:
|
|
235
|
+
return self._api("DELETE", f"/applications/{app_id}")
|
|
236
|
+
except ApiError as error:
|
|
237
|
+
body_text = json.dumps(error.body, ensure_ascii=False) if error.body is not None else ""
|
|
238
|
+
if error.status == 400 and "FAILED_PRECONDITION" in body_text:
|
|
239
|
+
error.hint = (
|
|
240
|
+
"в среде разработки приложения есть неопубликованные правки – "
|
|
241
|
+
"платформа не удаляет такие приложения через API; опубликуйте "
|
|
242
|
+
"или отмените правки, либо удалите приложение через панель управления"
|
|
243
|
+
)
|
|
244
|
+
error.message += " – " + error.hint
|
|
245
|
+
error.args = (error.message,)
|
|
246
|
+
raise
|
|
247
|
+
|
|
248
|
+
def start_app(self, app_id):
|
|
249
|
+
"""Запустить приложение."""
|
|
250
|
+
return self._api("PUT", f"/applications/{app_id}/status/start")
|
|
251
|
+
|
|
252
|
+
def stop_app(self, app_id):
|
|
253
|
+
"""Остановить приложение."""
|
|
254
|
+
return self._api("PUT", f"/applications/{app_id}/status/stop")
|
|
255
|
+
|
|
256
|
+
def apply_build(self, app_id, *, image_id=None, project_id=None, assembly_version=None):
|
|
257
|
+
"""Применить сборку к приложению (project/update).
|
|
258
|
+
|
|
259
|
+
Источник – либо image_id (id сборки), либо project_id с
|
|
260
|
+
необязательной assembly_version.
|
|
261
|
+
"""
|
|
262
|
+
if image_id:
|
|
263
|
+
source = {"type": "repository", "image-id": image_id}
|
|
264
|
+
elif project_id:
|
|
265
|
+
source = {"type": "repository", "project-id": project_id}
|
|
266
|
+
if assembly_version:
|
|
267
|
+
source["assembly-version"] = assembly_version
|
|
268
|
+
else:
|
|
269
|
+
raise ConfigError("нужен источник применения: image_id либо project_id")
|
|
270
|
+
return self._api(
|
|
271
|
+
"POST", f"/applications/{app_id}/project/update", json_body={"source": source}
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
def create_dump(self, app_id, *, include_users=True, include_binary_data=True, description=""):
|
|
275
|
+
"""Создать дамп приложения."""
|
|
276
|
+
body = {
|
|
277
|
+
"include-users": bool(include_users),
|
|
278
|
+
"include-binary-data": bool(include_binary_data),
|
|
279
|
+
"description": description or "",
|
|
280
|
+
}
|
|
281
|
+
return self._api("POST", f"/applications/{app_id}/dumps", json_body=body)
|
|
282
|
+
|
|
283
|
+
def get_dump(self, app_id, dump_id):
|
|
284
|
+
"""Статус дампа приложения."""
|
|
285
|
+
return self._api("GET", f"/applications/{app_id}/dumps/{dump_id}")
|
|
286
|
+
|
|
287
|
+
# -- версия технологии -------------------------------------------------
|
|
288
|
+
|
|
289
|
+
def get_technology_version(self, app_id):
|
|
290
|
+
"""Версия технологии – из карточки приложения."""
|
|
291
|
+
card = self.get_app(app_id) or {}
|
|
292
|
+
return card.get("technology-version")
|
|
293
|
+
|
|
294
|
+
def set_technology_version(self, version, app_ids):
|
|
295
|
+
"""Обновить версию технологии приложений; возвращает групповую задачу."""
|
|
296
|
+
body = {"technology-version": version, "applications": list(app_ids)}
|
|
297
|
+
return self._api(
|
|
298
|
+
"POST", "/tasks/group-tasks/update-applications-technology", json_body=body
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
def get_group_task(self, task_id):
|
|
302
|
+
"""Статус групповой задачи."""
|
|
303
|
+
return self._api("GET", f"/tasks/group-tasks/{task_id}")
|
|
304
|
+
|
|
305
|
+
# -- пространства и проекты ---------------------------------------------
|
|
306
|
+
|
|
307
|
+
def list_spaces(self):
|
|
308
|
+
"""Список пространств."""
|
|
309
|
+
return _as_list(self._api("GET", "/spaces"), "items", "spaces")
|
|
310
|
+
|
|
311
|
+
def list_projects(self):
|
|
312
|
+
"""Список проектов."""
|
|
313
|
+
return _as_list(self._api("GET", "/projects"), "items", "projects")
|
|
314
|
+
|
|
315
|
+
def get_project(self, project_id):
|
|
316
|
+
"""Карточка проекта."""
|
|
317
|
+
return self._api("GET", f"/projects/{project_id}")
|
|
318
|
+
|
|
319
|
+
def delete_project(self, project_id):
|
|
320
|
+
"""Удалить проект."""
|
|
321
|
+
return self._api("DELETE", f"/projects/{project_id}")
|
|
322
|
+
|
|
323
|
+
# -- сборки проектов -----------------------------------------------------
|
|
324
|
+
|
|
325
|
+
def upload_assembly(
|
|
326
|
+
self,
|
|
327
|
+
data,
|
|
328
|
+
*,
|
|
329
|
+
project_id=None,
|
|
330
|
+
space_id=None,
|
|
331
|
+
branch_name=None,
|
|
332
|
+
commit_id=None,
|
|
333
|
+
commit_message=None,
|
|
334
|
+
):
|
|
335
|
+
"""Загрузить файл сборки (.xasm/.xlib) на платформу.
|
|
336
|
+
|
|
337
|
+
С project_id сборка добавляется в существующий проект, без него
|
|
338
|
+
создаётся новый проект. Имена query-параметров у платформы – в
|
|
339
|
+
PascalCase.
|
|
340
|
+
"""
|
|
341
|
+
path = f"/projects/{project_id}/assemblies" if project_id else "/projects"
|
|
342
|
+
query = {
|
|
343
|
+
"SpaceId": space_id,
|
|
344
|
+
"BranchName": branch_name,
|
|
345
|
+
"CommitId": commit_id,
|
|
346
|
+
"CommitMessage": commit_message,
|
|
347
|
+
}
|
|
348
|
+
return self._api(
|
|
349
|
+
"POST",
|
|
350
|
+
path,
|
|
351
|
+
query=query,
|
|
352
|
+
data=bytes(data),
|
|
353
|
+
content_type="application/octet-stream",
|
|
354
|
+
)
|
|
355
|
+
|
|
356
|
+
def list_assemblies(self, project_id):
|
|
357
|
+
"""Список сборок проекта (нормализован к списку)."""
|
|
358
|
+
payload = self._api("GET", f"/projects/{project_id}/assemblies")
|
|
359
|
+
return _as_list(payload, "items", "assemblies")
|
|
360
|
+
|
|
361
|
+
def get_assembly(self, project_id, version):
|
|
362
|
+
"""Карточка сборки по версии."""
|
|
363
|
+
return self._api("GET", f"/projects/{project_id}/assemblies/{version}")
|
|
364
|
+
|
|
365
|
+
def delete_assembly(self, project_id, version):
|
|
366
|
+
"""Удалить сборку по версии."""
|
|
367
|
+
return self._api("DELETE", f"/projects/{project_id}/assemblies/{version}")
|
|
368
|
+
|
|
369
|
+
def latest_assembly(self, project_id):
|
|
370
|
+
"""Последняя сборка проекта по числовому счётчику версии, либо None."""
|
|
371
|
+
return pick_latest(self.list_assemblies(project_id))
|
|
372
|
+
|
|
373
|
+
# -- ветки среды разработки ----------------------------------------------
|
|
374
|
+
|
|
375
|
+
def list_branches(self, project_id="", name=""):
|
|
376
|
+
"""Список веток; фильтры project-id и name необязательны."""
|
|
377
|
+
payload = self._api(
|
|
378
|
+
"GET", "/branches", query={"project-id": project_id, "name": name}
|
|
379
|
+
)
|
|
380
|
+
return _as_list(payload, "items", "branches")
|
|
381
|
+
|
|
382
|
+
def get_branch(self, branch_id):
|
|
383
|
+
"""Карточка ветки."""
|
|
384
|
+
return self._api("GET", f"/branches/{branch_id}")
|
|
385
|
+
|
|
386
|
+
def create_branch(self, name, project_id, app_id=None):
|
|
387
|
+
"""Создать ветку среды разработки."""
|
|
388
|
+
body = {"name": name, "kind": "development", "project": {"id": project_id}}
|
|
389
|
+
if app_id:
|
|
390
|
+
body["application"] = {"id": app_id}
|
|
391
|
+
return self._api("POST", "/branches", json_body=body)
|
|
392
|
+
|
|
393
|
+
def update_branch(self, branch_id, *, app_id=None, merge=False):
|
|
394
|
+
"""Изменить ветку с учётом оптимистической блокировки платформы.
|
|
395
|
+
|
|
396
|
+
Сначала читается карточка, затем отправляется тело из текущих
|
|
397
|
+
значений (version-stamp обязательно возвращается как есть);
|
|
398
|
+
app_id перепривязывает ветку к приложению, merge=True добавляет
|
|
399
|
+
write-parameters и означает принятие изменений ветки.
|
|
400
|
+
"""
|
|
401
|
+
card = self.get_branch(branch_id) or {}
|
|
402
|
+
body = {
|
|
403
|
+
"name": card.get("name"),
|
|
404
|
+
"kind": card.get("kind"),
|
|
405
|
+
"deletion-mark": card.get("deletion-mark", False),
|
|
406
|
+
"version-stamp": card.get("version-stamp"),
|
|
407
|
+
}
|
|
408
|
+
for key in ("source-branch", "application"):
|
|
409
|
+
collapsed = _collapse_reference(card.get(key))
|
|
410
|
+
if collapsed is not None:
|
|
411
|
+
body[key] = collapsed
|
|
412
|
+
if app_id:
|
|
413
|
+
body["application"] = {"id": app_id}
|
|
414
|
+
if merge:
|
|
415
|
+
body["write-parameters"] = {"merge": True}
|
|
416
|
+
return self._api("PUT", f"/branches/{branch_id}", json_body=body)
|
|
417
|
+
|
|
418
|
+
def merge_branch(self, branch_id):
|
|
419
|
+
"""Принять изменения ветки (merge)."""
|
|
420
|
+
return self.update_branch(branch_id, merge=True)
|
|
421
|
+
|
|
422
|
+
def delete_branch(self, branch_id):
|
|
423
|
+
"""Удалить ветку."""
|
|
424
|
+
return self._api("DELETE", f"/branches/{branch_id}")
|
|
425
|
+
|
|
426
|
+
def sync_branch(self, app_id, branch_id):
|
|
427
|
+
"""Загрузить изменения из git-ветки платформы в приложение.
|
|
428
|
+
|
|
429
|
+
Внимание: это внутренний UI API консоли (не Console API v2),
|
|
430
|
+
его контракт наименее стабильный.
|
|
431
|
+
"""
|
|
432
|
+
body = {
|
|
433
|
+
"module": "e1c::console::Applications::ApplicationConfigurationUpdateForm",
|
|
434
|
+
"method": "UpdateAppConfiguration",
|
|
435
|
+
"params": [
|
|
436
|
+
{
|
|
437
|
+
"type": "e1c::console::Applications::Applications.Reference",
|
|
438
|
+
"value": app_id,
|
|
439
|
+
},
|
|
440
|
+
{
|
|
441
|
+
"type": "e1c::console::Team::Branches.Reference",
|
|
442
|
+
"value": branch_id,
|
|
443
|
+
},
|
|
444
|
+
{"type": "Std::Boolean", "value": False},
|
|
445
|
+
{"type": "Std::Boolean", "value": False},
|
|
446
|
+
],
|
|
447
|
+
}
|
|
448
|
+
return self._request(
|
|
449
|
+
"POST", "/console/ui/module/call", query={"locale": "ru"}, json_body=body
|
|
450
|
+
)
|
|
451
|
+
|
|
452
|
+
# -- задачи приложений ----------------------------------------------------
|
|
453
|
+
|
|
454
|
+
def list_app_tasks(self, app_id=""):
|
|
455
|
+
"""Задачи приложений; серверного фильтра нет – фильтруем на клиенте."""
|
|
456
|
+
payload = self._api("GET", "/tasks/application-tasks")
|
|
457
|
+
tasks = _as_list(payload, "items", "tasks")
|
|
458
|
+
if not app_id:
|
|
459
|
+
return tasks
|
|
460
|
+
return [
|
|
461
|
+
task
|
|
462
|
+
for task in tasks
|
|
463
|
+
if isinstance(task, dict) and task.get("application-id") == app_id
|
|
464
|
+
]
|
|
465
|
+
|
|
466
|
+
# -- ожидания состояний -----------------------------------------------------
|
|
467
|
+
|
|
468
|
+
def wait_app_status(
|
|
469
|
+
self,
|
|
470
|
+
app_id,
|
|
471
|
+
target_statuses,
|
|
472
|
+
*,
|
|
473
|
+
timeout,
|
|
474
|
+
poll=POLL_INTERVAL,
|
|
475
|
+
log=None,
|
|
476
|
+
error_is_fatal=True,
|
|
477
|
+
):
|
|
478
|
+
"""Дождаться одного из целевых статусов приложения; вернуть карточку.
|
|
479
|
+
|
|
480
|
+
При error_is_fatal статус Error (если он не целевой) – немедленная
|
|
481
|
+
ошибка; по истечении таймаута тоже ошибка.
|
|
482
|
+
"""
|
|
483
|
+
deadline = time.monotonic() + timeout
|
|
484
|
+
while True:
|
|
485
|
+
card = self.get_app(app_id) or {}
|
|
486
|
+
status = (card.get("status") or "").strip()
|
|
487
|
+
if status in target_statuses:
|
|
488
|
+
return card
|
|
489
|
+
if error_is_fatal and status == "Error":
|
|
490
|
+
raise ApiError(
|
|
491
|
+
f"приложение {app_id} в статусе Error: {card.get('error') or 'без текста ошибки'}",
|
|
492
|
+
body=card,
|
|
493
|
+
)
|
|
494
|
+
if time.monotonic() >= deadline:
|
|
495
|
+
expected = "/".join(sorted(target_statuses))
|
|
496
|
+
raise ApiError(
|
|
497
|
+
f"не дождались статуса {expected} приложения {app_id} "
|
|
498
|
+
f"за {int(timeout)} с (текущий: {status or 'переходный'})"
|
|
499
|
+
)
|
|
500
|
+
if log:
|
|
501
|
+
log(f"статус приложения: {status or '(переходный)'} – ждём...")
|
|
502
|
+
self._sleep(poll)
|
|
503
|
+
|
|
504
|
+
def wait_app_stable(self, app_id, *, timeout=START_TIMEOUT, poll=POLL_INTERVAL, log=None):
|
|
505
|
+
"""Дождаться выхода приложения из переходных статусов."""
|
|
506
|
+
return self.wait_app_status(
|
|
507
|
+
app_id, STABLE_STATUSES, timeout=timeout, poll=poll, log=log, error_is_fatal=False
|
|
508
|
+
)
|
|
509
|
+
|
|
510
|
+
def wait_app_ready(self, app_id, *, timeout=READY_TIMEOUT, poll=POLL_INTERVAL, log=None):
|
|
511
|
+
"""Дождаться готовности нового приложения: стабильный статус и uri.
|
|
512
|
+
|
|
513
|
+
Статус Error во время ожидания – немедленная ошибка.
|
|
514
|
+
"""
|
|
515
|
+
deadline = time.monotonic() + timeout
|
|
516
|
+
while True:
|
|
517
|
+
card = self.get_app(app_id) or {}
|
|
518
|
+
status = (card.get("status") or "").strip()
|
|
519
|
+
if status == "Error":
|
|
520
|
+
raise ApiError(
|
|
521
|
+
f"приложение {app_id} создано со статусом Error: "
|
|
522
|
+
f"{card.get('error') or 'без текста ошибки'}",
|
|
523
|
+
body=card,
|
|
524
|
+
)
|
|
525
|
+
if status in ("Running", "Stopped") and card.get("uri"):
|
|
526
|
+
return card
|
|
527
|
+
if time.monotonic() >= deadline:
|
|
528
|
+
raise ApiError(
|
|
529
|
+
f"приложение {app_id} не стало готовым за {int(timeout)} с "
|
|
530
|
+
f"(статус: {status or 'переходный'}, uri: {card.get('uri') or 'нет'})"
|
|
531
|
+
)
|
|
532
|
+
if log:
|
|
533
|
+
log(f"ждём готовности приложения: статус {status or '(переходный)'}...")
|
|
534
|
+
self._sleep(poll)
|
|
535
|
+
|
|
536
|
+
def ensure_running(self, app_id, *, log=None):
|
|
537
|
+
"""Довести приложение до статуса Running после применения сборки.
|
|
538
|
+
|
|
539
|
+
Применение может само перезапустить приложение: ждём стабилизации;
|
|
540
|
+
если итог не Running – останавливаем (если нужно), дожидаемся
|
|
541
|
+
Stopped, запускаем и дожидаемся Running.
|
|
542
|
+
"""
|
|
543
|
+
card = self.wait_app_stable(app_id, timeout=START_TIMEOUT, log=log)
|
|
544
|
+
status = card.get("status")
|
|
545
|
+
if status == "Running":
|
|
546
|
+
return card
|
|
547
|
+
if status != "Stopped":
|
|
548
|
+
if log:
|
|
549
|
+
log(f"статус {status} – останавливаем приложение...")
|
|
550
|
+
self.stop_app(app_id)
|
|
551
|
+
self.wait_app_status(
|
|
552
|
+
app_id, {"Stopped"}, timeout=STOP_TIMEOUT, log=log, error_is_fatal=False
|
|
553
|
+
)
|
|
554
|
+
if log:
|
|
555
|
+
log("запускаем приложение...")
|
|
556
|
+
self.start_app(app_id)
|
|
557
|
+
return self.wait_app_status(app_id, {"Running"}, timeout=START_TIMEOUT, log=log)
|
elemctl/config.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""Конфигурация подключения к платформе.
|
|
2
|
+
|
|
3
|
+
Параметры собираются из трёх источников по убыванию приоритета:
|
|
4
|
+
явные аргументы, переменные окружения, .env-файл (раздел 2 спецификации).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from .errors import ConfigError
|
|
14
|
+
|
|
15
|
+
# Соответствие полей конфигурации переменным окружения.
|
|
16
|
+
ENV_KEYS = {
|
|
17
|
+
"base_url": "ELEMENT_BASE_URL",
|
|
18
|
+
"client_id": "ELEMENT_CLIENT_ID",
|
|
19
|
+
"client_secret": "ELEMENT_CLIENT_SECRET",
|
|
20
|
+
"app_id": "ELEMENT_APP_ID",
|
|
21
|
+
"project_id": "ELEMENT_PROJECT_ID",
|
|
22
|
+
"space_id": "ELEMENT_SPACE_ID",
|
|
23
|
+
"branch_id": "ELEMENT_BRANCH_ID",
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
DEFAULT_TIMEOUT = 60.0
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def parse_env_file(path):
|
|
30
|
+
"""Разобрать .env-файл в словарь KEY -> VALUE.
|
|
31
|
+
|
|
32
|
+
Правила: пустые строки и строки с "#" в начале пропускаются, допускается
|
|
33
|
+
префикс "export " и одинарные или двойные кавычки вокруг значения;
|
|
34
|
+
кодировка UTF-8, возможен BOM.
|
|
35
|
+
"""
|
|
36
|
+
values = {}
|
|
37
|
+
text = Path(path).read_text(encoding="utf-8-sig")
|
|
38
|
+
for raw_line in text.splitlines():
|
|
39
|
+
line = raw_line.strip()
|
|
40
|
+
if not line or line.startswith("#"):
|
|
41
|
+
continue
|
|
42
|
+
if line.startswith("export "):
|
|
43
|
+
line = line[len("export "):].strip()
|
|
44
|
+
if "=" not in line:
|
|
45
|
+
continue
|
|
46
|
+
key, _, value = line.partition("=")
|
|
47
|
+
key = key.strip()
|
|
48
|
+
value = value.strip()
|
|
49
|
+
if len(value) >= 2 and value[0] == value[-1] and value[0] in ("'", '"'):
|
|
50
|
+
value = value[1:-1]
|
|
51
|
+
if key:
|
|
52
|
+
values[key] = value
|
|
53
|
+
return values
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass
|
|
57
|
+
class Config:
|
|
58
|
+
"""Параметры подключения к Console API."""
|
|
59
|
+
|
|
60
|
+
base_url: str = ""
|
|
61
|
+
client_id: str = ""
|
|
62
|
+
client_secret: str = ""
|
|
63
|
+
app_id: str = ""
|
|
64
|
+
project_id: str = ""
|
|
65
|
+
space_id: str = ""
|
|
66
|
+
branch_id: str = ""
|
|
67
|
+
timeout: float = field(default=DEFAULT_TIMEOUT)
|
|
68
|
+
|
|
69
|
+
def __post_init__(self):
|
|
70
|
+
# Хвостовой слэш базового URL всегда обрезается.
|
|
71
|
+
self.base_url = (self.base_url or "").rstrip("/")
|
|
72
|
+
|
|
73
|
+
@classmethod
|
|
74
|
+
def from_env(cls, env_file=None, environ=None, **overrides):
|
|
75
|
+
"""Собрать конфигурацию: явные аргументы > окружение > .env-файл.
|
|
76
|
+
|
|
77
|
+
env_file – путь к .env; без него берётся файл .env в текущем
|
|
78
|
+
каталоге, если существует. environ – источник переменных окружения
|
|
79
|
+
(по умолчанию os.environ; параметр нужен тестам).
|
|
80
|
+
"""
|
|
81
|
+
env = os.environ if environ is None else environ
|
|
82
|
+
|
|
83
|
+
file_values = {}
|
|
84
|
+
if env_file:
|
|
85
|
+
path = Path(env_file)
|
|
86
|
+
if not path.is_file():
|
|
87
|
+
raise ConfigError(f".env-файл не найден: {path}")
|
|
88
|
+
file_values = parse_env_file(path)
|
|
89
|
+
else:
|
|
90
|
+
default_path = Path(".env")
|
|
91
|
+
if default_path.is_file():
|
|
92
|
+
file_values = parse_env_file(default_path)
|
|
93
|
+
|
|
94
|
+
values = {}
|
|
95
|
+
for field_name, env_key in ENV_KEYS.items():
|
|
96
|
+
override = overrides.pop(field_name, None)
|
|
97
|
+
if override not in (None, ""):
|
|
98
|
+
values[field_name] = str(override)
|
|
99
|
+
elif env.get(env_key):
|
|
100
|
+
values[field_name] = env[env_key]
|
|
101
|
+
elif file_values.get(env_key):
|
|
102
|
+
values[field_name] = file_values[env_key]
|
|
103
|
+
|
|
104
|
+
timeout = overrides.pop("timeout", None)
|
|
105
|
+
if timeout:
|
|
106
|
+
values["timeout"] = float(timeout)
|
|
107
|
+
|
|
108
|
+
if overrides:
|
|
109
|
+
unknown = ", ".join(sorted(overrides))
|
|
110
|
+
raise TypeError(f"неизвестные параметры конфигурации: {unknown}")
|
|
111
|
+
|
|
112
|
+
return cls(**values)
|
|
113
|
+
|
|
114
|
+
def require(self):
|
|
115
|
+
"""Проверить обязательные параметры подключения; вернуть self."""
|
|
116
|
+
missing = []
|
|
117
|
+
for field_name in ("base_url", "client_id", "client_secret"):
|
|
118
|
+
if not getattr(self, field_name):
|
|
119
|
+
missing.append(ENV_KEYS[field_name])
|
|
120
|
+
if missing:
|
|
121
|
+
raise ConfigError(
|
|
122
|
+
"не заданы параметры подключения: " + ", ".join(missing)
|
|
123
|
+
+ " (переменные окружения, .env-файл или флаги CLI)"
|
|
124
|
+
)
|
|
125
|
+
return self
|