elemctl 0.2.0__tar.gz → 0.3.0__tar.gz
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-0.2.0 → elemctl-0.3.0}/PKG-INFO +8 -1
- {elemctl-0.2.0 → elemctl-0.3.0}/README.md +7 -0
- {elemctl-0.2.0 → elemctl-0.3.0}/docs/SPEC.md +19 -2
- {elemctl-0.2.0 → elemctl-0.3.0}/pyproject.toml +1 -1
- {elemctl-0.2.0 → elemctl-0.3.0}/src/elemctl/__init__.py +1 -1
- elemctl-0.3.0/src/elemctl/__main__.py +14 -0
- {elemctl-0.2.0 → elemctl-0.3.0}/src/elemctl/cli.py +69 -14
- {elemctl-0.2.0 → elemctl-0.3.0}/src/elemctl/client.py +22 -2
- {elemctl-0.2.0 → elemctl-0.3.0}/src/elemctl/mcp_server.py +47 -12
- {elemctl-0.2.0 → elemctl-0.3.0}/src/elemctl.egg-info/PKG-INFO +8 -1
- {elemctl-0.2.0 → elemctl-0.3.0}/src/elemctl.egg-info/SOURCES.txt +1 -0
- elemctl-0.3.0/tests/test_cli.py +259 -0
- {elemctl-0.2.0 → elemctl-0.3.0}/tests/test_client.py +28 -0
- {elemctl-0.2.0 → elemctl-0.3.0}/tests/test_mcp.py +18 -0
- elemctl-0.2.0/tests/test_cli.py +0 -144
- {elemctl-0.2.0 → elemctl-0.3.0}/.env.example +0 -0
- {elemctl-0.2.0 → elemctl-0.3.0}/LICENSE +0 -0
- {elemctl-0.2.0 → elemctl-0.3.0}/MANIFEST.in +0 -0
- {elemctl-0.2.0 → elemctl-0.3.0}/NOTICE +0 -0
- {elemctl-0.2.0 → elemctl-0.3.0}/ORIGIN.md +0 -0
- {elemctl-0.2.0 → elemctl-0.3.0}/setup.cfg +0 -0
- {elemctl-0.2.0 → elemctl-0.3.0}/src/elemctl/auth.py +0 -0
- {elemctl-0.2.0 → elemctl-0.3.0}/src/elemctl/build.py +0 -0
- {elemctl-0.2.0 → elemctl-0.3.0}/src/elemctl/config.py +0 -0
- {elemctl-0.2.0 → elemctl-0.3.0}/src/elemctl/deploy.py +0 -0
- {elemctl-0.2.0 → elemctl-0.3.0}/src/elemctl/errors.py +0 -0
- {elemctl-0.2.0 → elemctl-0.3.0}/src/elemctl/transport.py +0 -0
- {elemctl-0.2.0 → elemctl-0.3.0}/src/elemctl/versions.py +0 -0
- {elemctl-0.2.0 → elemctl-0.3.0}/src/elemctl.egg-info/dependency_links.txt +0 -0
- {elemctl-0.2.0 → elemctl-0.3.0}/src/elemctl.egg-info/entry_points.txt +0 -0
- {elemctl-0.2.0 → elemctl-0.3.0}/src/elemctl.egg-info/requires.txt +0 -0
- {elemctl-0.2.0 → elemctl-0.3.0}/src/elemctl.egg-info/top_level.txt +0 -0
- {elemctl-0.2.0 → elemctl-0.3.0}/tests/test_build.py +0 -0
- {elemctl-0.2.0 → elemctl-0.3.0}/tests/test_config.py +0 -0
- {elemctl-0.2.0 → elemctl-0.3.0}/tests/test_deploy.py +0 -0
- {elemctl-0.2.0 → elemctl-0.3.0}/tests/test_versions.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: elemctl
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: CLI, MCP-сервер и библиотека для Console API v2 платформы 1С:Предприятие.Элемент (1cmycloud)
|
|
5
5
|
Author: KeyFire
|
|
6
6
|
License-Expression: MIT
|
|
@@ -112,6 +112,9 @@ elemctl apps list
|
|
|
112
112
|
# карточка приложения (статус, uri, фактическая версия проекта)
|
|
113
113
|
elemctl apps get <app-id>
|
|
114
114
|
|
|
115
|
+
# создать приложение, только если его ещё нет: {"id": ..., "created": true|false}
|
|
116
|
+
elemctl apps ensure acme-crm-dev --project-id <project-id> --latest-build --wait
|
|
117
|
+
|
|
115
118
|
# полный цикл деплоя из исходников с проверкой применения
|
|
116
119
|
elemctl deploy --app-id <app-id> --project-id <project-id> --project-dir acme/crm
|
|
117
120
|
|
|
@@ -184,6 +187,10 @@ Assembly.yaml # манифест: ProjectKind, Vendor, Name, Version,
|
|
|
184
187
|
`elemctl apps create <имя> --project-id <id> --latest-build` (MCP-инструмент
|
|
185
188
|
`create_app` подставляет последнюю сборку автоматически), а после создания -
|
|
186
189
|
`elemctl deploy`.
|
|
190
|
+
- Удалённые приложения остаются в списке платформы со статусом `Deleted` и
|
|
191
|
+
прежним `id`, на котором `apps get` и `deploy` отвечают 404. `apps find` и
|
|
192
|
+
`apps ensure` их пропускают; вернуть прежний поиск - `apps find
|
|
193
|
+
--include-deleted`.
|
|
187
194
|
- Приложение с неопубликованными правками в среде разработки платформа
|
|
188
195
|
удалить не даёт (HTTP 400 `FAILED_PRECONDITION`), принудительного удаления
|
|
189
196
|
в Console API нет - только через панель управления; elemctl подскажет это
|
|
@@ -84,6 +84,9 @@ elemctl apps list
|
|
|
84
84
|
# карточка приложения (статус, uri, фактическая версия проекта)
|
|
85
85
|
elemctl apps get <app-id>
|
|
86
86
|
|
|
87
|
+
# создать приложение, только если его ещё нет: {"id": ..., "created": true|false}
|
|
88
|
+
elemctl apps ensure acme-crm-dev --project-id <project-id> --latest-build --wait
|
|
89
|
+
|
|
87
90
|
# полный цикл деплоя из исходников с проверкой применения
|
|
88
91
|
elemctl deploy --app-id <app-id> --project-id <project-id> --project-dir acme/crm
|
|
89
92
|
|
|
@@ -156,6 +159,10 @@ Assembly.yaml # манифест: ProjectKind, Vendor, Name, Version,
|
|
|
156
159
|
`elemctl apps create <имя> --project-id <id> --latest-build` (MCP-инструмент
|
|
157
160
|
`create_app` подставляет последнюю сборку автоматически), а после создания -
|
|
158
161
|
`elemctl deploy`.
|
|
162
|
+
- Удалённые приложения остаются в списке платформы со статусом `Deleted` и
|
|
163
|
+
прежним `id`, на котором `apps get` и `deploy` отвечают 404. `apps find` и
|
|
164
|
+
`apps ensure` их пропускают; вернуть прежний поиск - `apps find
|
|
165
|
+
--include-deleted`.
|
|
159
166
|
- Приложение с неопубликованными правками в среде разработки платформа
|
|
160
167
|
удалить не даёт (HTTP 400 `FAILED_PRECONDITION`), принудительного удаления
|
|
161
168
|
в Console API нет - только через панель управления; elemctl подскажет это
|
|
@@ -255,13 +255,30 @@ stderr и код возврата 1.
|
|
|
255
255
|
Команды (в скобках - существенные флаги):
|
|
256
256
|
|
|
257
257
|
- `token` - получить и напечатать токен.
|
|
258
|
-
- `apps list [--name]`, `apps get [APP_ID]`, `apps find NAME`,
|
|
258
|
+
- `apps list [--name]`, `apps get [APP_ID]`, `apps find NAME [--include-deleted]`,
|
|
259
259
|
`apps create NAME [--project-id --version-id --latest-build --space-id
|
|
260
|
+
--tech-version --no-dev-mode --wait]`,
|
|
261
|
+
`apps ensure NAME [--project-id --version-id --latest-build --space-id
|
|
260
262
|
--tech-version --no-dev-mode --wait]`, `apps delete APP_ID`,
|
|
261
263
|
`apps start [APP_ID]`, `apps stop [APP_ID]`.
|
|
262
264
|
- `apps find` ищет по точному совпадению (без учёта регистра) имени среди
|
|
263
265
|
полей `name`, `display-name`, `publication-context`; вывод
|
|
264
|
-
`{"id": ..., "found": true|false}`, код возврата
|
|
266
|
+
`{"id": ..., "found": true|false}`, код возврата 0 в обоих случаях –
|
|
267
|
+
отсутствие приложения это ответ, а не ошибка. Ненулевой код возврата
|
|
268
|
+
означает сбой запроса и сопровождается JSON'ом с полем `error` в stderr.
|
|
269
|
+
В скриптах проверять поле `found`, а не код возврата.
|
|
270
|
+
- Удалённые приложения остаются в списке платформы со статусом `Deleted` и
|
|
271
|
+
прежним `id`. `apps find` их ПРОПУСКАЕТ: найденный id обязан быть пригоден
|
|
272
|
+
для работы, иначе вызывающий получает id, на котором `apps get` и `deploy`
|
|
273
|
+
отвечают 404. Флаг `--include-deleted` возвращает прежнее поведение – поиск
|
|
274
|
+
среди всех приложений, включая удалённые.
|
|
275
|
+
- `apps ensure` идемпотентно приводит приложение с данным именем в
|
|
276
|
+
существование: ищет по правилам `apps find` (удалённые не в счёт) и создаёт
|
|
277
|
+
только при отсутствии. Вывод `{"id": ..., "created": true|false}`;
|
|
278
|
+
`created: false` означает, что приложение уже было и НЕ трогалось. Флаги
|
|
279
|
+
создания те же, что у `apps create`, и действуют, только когда создание
|
|
280
|
+
происходит. Существующее приложение никогда не пересоздаётся: `delete` +
|
|
281
|
+
`create` дают новый URL и рвут внешние привязки к прежнему.
|
|
265
282
|
- `--latest-build` - взять источником последнюю сборку проекта (защита от
|
|
266
283
|
пустого каркаса, п. 6.2); `--wait` - дождаться готовности (п. 6.4) и
|
|
267
284
|
вывести финальную карточку.
|
|
@@ -6,7 +6,7 @@ build-backend = "setuptools.build_meta"
|
|
|
6
6
|
|
|
7
7
|
[project]
|
|
8
8
|
name = "elemctl"
|
|
9
|
-
version = "0.
|
|
9
|
+
version = "0.3.0"
|
|
10
10
|
description = "CLI, MCP-сервер и библиотека для Console API v2 платформы 1С:Предприятие.Элемент (1cmycloud)"
|
|
11
11
|
readme = "README.md"
|
|
12
12
|
requires-python = ">=3.10"
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Запуск пакета как модуля: python -m elemctl <аргументы>.
|
|
2
|
+
|
|
3
|
+
Нужен тем, кто вызывает elemctl текущим интерпретатором, не полагаясь на
|
|
4
|
+
консольную точку входа в PATH (например, обёртки в чужих репозиториях).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import sys
|
|
10
|
+
|
|
11
|
+
from .cli import main
|
|
12
|
+
|
|
13
|
+
if __name__ == "__main__":
|
|
14
|
+
sys.exit(main())
|
|
@@ -97,18 +97,32 @@ def cmd_apps_get(args):
|
|
|
97
97
|
|
|
98
98
|
|
|
99
99
|
def cmd_apps_find(args):
|
|
100
|
+
"""Поиск приложения по имени. Отсутствие приложения – это ответ, а не ошибка.
|
|
101
|
+
|
|
102
|
+
Код возврата 0 в обоих случаях: признак несёт поле found. Ненулевой код возврата
|
|
103
|
+
означает сбой запроса (нет доступа, сеть, конфигурация) и сопровождается JSON'ом
|
|
104
|
+
с полем error в stderr – иначе вызывающий не отличит "стенда нет" от "не смогли спросить".
|
|
105
|
+
|
|
106
|
+
Удалённые приложения (статус Deleted) по умолчанию пропускаются, чтобы найденный id
|
|
107
|
+
был пригоден для работы; флаг --include-deleted возвращает поиск среди всех приложений.
|
|
108
|
+
"""
|
|
100
109
|
client = make_client(_config(args))
|
|
101
|
-
app = client.find_app(args.name)
|
|
110
|
+
app = client.find_app(args.name, include_deleted=args.include_deleted)
|
|
102
111
|
if app is None:
|
|
103
112
|
_emit({"id": None, "found": False})
|
|
104
|
-
return
|
|
113
|
+
return 0
|
|
105
114
|
_emit({"id": app.get("id"), "found": True})
|
|
106
115
|
return 0
|
|
107
116
|
|
|
108
117
|
|
|
109
|
-
def
|
|
110
|
-
|
|
111
|
-
|
|
118
|
+
def _create_app_from_args(client, config, args):
|
|
119
|
+
"""Создать приложение по флагам создания; вернуть карточку.
|
|
120
|
+
|
|
121
|
+
Общая логика apps create и apps ensure. Источник – указанная сборка
|
|
122
|
+
(--version-id), последняя сборка проекта (--latest-build) либо проект
|
|
123
|
+
целиком (--project-id, чревато пустым каркасом). При --wait дожидается
|
|
124
|
+
готовности приложения.
|
|
125
|
+
"""
|
|
112
126
|
project_id = args.project_id or config.project_id
|
|
113
127
|
version_id = args.version_id
|
|
114
128
|
|
|
@@ -143,7 +157,34 @@ def cmd_apps_create(args):
|
|
|
143
157
|
app_id = (card or {}).get("id")
|
|
144
158
|
if app_id:
|
|
145
159
|
card = client.wait_app_ready(app_id, log=_progress)
|
|
146
|
-
|
|
160
|
+
return card
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def cmd_apps_create(args):
|
|
164
|
+
config = _config(args)
|
|
165
|
+
client = make_client(config)
|
|
166
|
+
_emit(_create_app_from_args(client, config, args))
|
|
167
|
+
return 0
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def cmd_apps_ensure(args):
|
|
171
|
+
"""Идемпотентно привести приложение с данным именем в существование.
|
|
172
|
+
|
|
173
|
+
Ищет приложение по правилам apps find (удалённые в статусе Deleted не в
|
|
174
|
+
счёт): если оно уже есть – ничего не делает и возвращает created: false;
|
|
175
|
+
если нет – создаёт по флагам создания и возвращает created: true.
|
|
176
|
+
Существующее приложение никогда не пересоздаётся: delete + create дают
|
|
177
|
+
новый URL и рвут внешние привязки к прежнему. Код возврата 0 в обоих
|
|
178
|
+
случаях; сбой запроса – JSON с полем error в stderr и код возврата 1.
|
|
179
|
+
"""
|
|
180
|
+
config = _config(args)
|
|
181
|
+
client = make_client(config)
|
|
182
|
+
existing = client.find_app(args.name)
|
|
183
|
+
if existing is not None:
|
|
184
|
+
_emit({"id": existing.get("id"), "created": False})
|
|
185
|
+
return 0
|
|
186
|
+
card = _create_app_from_args(client, config, args)
|
|
187
|
+
_emit({"id": (card or {}).get("id"), "created": True})
|
|
147
188
|
return 0
|
|
148
189
|
|
|
149
190
|
|
|
@@ -394,6 +435,18 @@ def cmd_mcp(args):
|
|
|
394
435
|
# -- парсер --------------------------------------------------------------------
|
|
395
436
|
|
|
396
437
|
|
|
438
|
+
def _add_create_flags(p):
|
|
439
|
+
"""Добавить флаги источника и создания приложения (общие для apps create и apps ensure)."""
|
|
440
|
+
p.add_argument("name", metavar="NAME")
|
|
441
|
+
p.add_argument("--project-id", help="проект-источник")
|
|
442
|
+
p.add_argument("--version-id", help="id сборки-источника")
|
|
443
|
+
p.add_argument("--latest-build", action="store_true", help="источник – последняя сборка проекта")
|
|
444
|
+
p.add_argument("--space-id", help="пространство")
|
|
445
|
+
p.add_argument("--tech-version", help="версия технологии")
|
|
446
|
+
p.add_argument("--no-dev-mode", action="store_true", help="не создавать среду разработки")
|
|
447
|
+
p.add_argument("--wait", action="store_true", help="дождаться готовности приложения")
|
|
448
|
+
|
|
449
|
+
|
|
397
450
|
def build_parser():
|
|
398
451
|
parser = argparse.ArgumentParser(
|
|
399
452
|
prog="elemctl",
|
|
@@ -425,19 +478,21 @@ def build_parser():
|
|
|
425
478
|
|
|
426
479
|
p = apps_sub.add_parser("find", help="найти приложение по имени (точное совпадение без учёта регистра)")
|
|
427
480
|
p.add_argument("name", metavar="NAME")
|
|
481
|
+
p.add_argument(
|
|
482
|
+
"--include-deleted",
|
|
483
|
+
action="store_true",
|
|
484
|
+
help="искать и среди удалённых приложений (по умолчанию пропускаются)",
|
|
485
|
+
)
|
|
428
486
|
p.set_defaults(handler=cmd_apps_find)
|
|
429
487
|
|
|
430
488
|
p = apps_sub.add_parser("create", help="создать приложение")
|
|
431
|
-
p
|
|
432
|
-
p.add_argument("--project-id", help="проект-источник")
|
|
433
|
-
p.add_argument("--version-id", help="id сборки-источника")
|
|
434
|
-
p.add_argument("--latest-build", action="store_true", help="источник – последняя сборка проекта")
|
|
435
|
-
p.add_argument("--space-id", help="пространство")
|
|
436
|
-
p.add_argument("--tech-version", help="версия технологии")
|
|
437
|
-
p.add_argument("--no-dev-mode", action="store_true", help="не создавать среду разработки")
|
|
438
|
-
p.add_argument("--wait", action="store_true", help="дождаться готовности и вывести карточку")
|
|
489
|
+
_add_create_flags(p)
|
|
439
490
|
p.set_defaults(handler=cmd_apps_create)
|
|
440
491
|
|
|
492
|
+
p = apps_sub.add_parser("ensure", help="создать приложение, если его ещё нет (идемпотентно)")
|
|
493
|
+
_add_create_flags(p)
|
|
494
|
+
p.set_defaults(handler=cmd_apps_ensure)
|
|
495
|
+
|
|
441
496
|
p = apps_sub.add_parser("delete", help="удалить приложение (необратимо, URL меняется при пересоздании)")
|
|
442
497
|
p.add_argument("app_id", metavar="APP_ID")
|
|
443
498
|
p.set_defaults(handler=cmd_apps_delete)
|
|
@@ -70,6 +70,21 @@ def _collapse_reference(value):
|
|
|
70
70
|
return None
|
|
71
71
|
|
|
72
72
|
|
|
73
|
+
# Статус удалённого приложения (сравнение без учёта регистра).
|
|
74
|
+
DELETED_STATUS = "Deleted"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _is_deleted(app):
|
|
78
|
+
"""Признак удалённого приложения.
|
|
79
|
+
|
|
80
|
+
Платформа не убирает удалённые приложения из списка, а помечает их
|
|
81
|
+
статусом Deleted, сохраняя прежний id. На таком id последующие get и
|
|
82
|
+
deploy отвечают 404, поэтому поиск по умолчанию их пропускает.
|
|
83
|
+
"""
|
|
84
|
+
status = app.get("status")
|
|
85
|
+
return isinstance(status, str) and status.strip().lower() == DELETED_STATUS.lower()
|
|
86
|
+
|
|
87
|
+
|
|
73
88
|
class ElementClient:
|
|
74
89
|
"""Программный клиент Console API v2."""
|
|
75
90
|
|
|
@@ -167,11 +182,14 @@ class ElementClient:
|
|
|
167
182
|
"""Карточка приложения (статус, uri, source.project-version и др.)."""
|
|
168
183
|
return self._api("GET", f"/applications/{app_id}")
|
|
169
184
|
|
|
170
|
-
def find_app(self, name):
|
|
185
|
+
def find_app(self, name, *, include_deleted=False):
|
|
171
186
|
"""Найти приложение по точному совпадению имени без учёта регистра.
|
|
172
187
|
|
|
173
188
|
Имя сверяется с полями name, display-name и publication-context.
|
|
174
|
-
|
|
189
|
+
Удалённые приложения (статус Deleted) по умолчанию пропускаются: на их
|
|
190
|
+
прежнем id последующие get и deploy отвечают 404. include_deleted=True
|
|
191
|
+
возвращает прежнее поведение – поиск среди всех приложений, включая
|
|
192
|
+
удалённые. Возвращается карточка из списка либо None.
|
|
175
193
|
"""
|
|
176
194
|
target = (name or "").strip().lower()
|
|
177
195
|
if not target:
|
|
@@ -179,6 +197,8 @@ class ElementClient:
|
|
|
179
197
|
for app in self.list_apps():
|
|
180
198
|
if not isinstance(app, dict):
|
|
181
199
|
continue
|
|
200
|
+
if not include_deleted and _is_deleted(app):
|
|
201
|
+
continue
|
|
182
202
|
for key in ("name", "display-name", "publication-context"):
|
|
183
203
|
value = app.get(key)
|
|
184
204
|
if isinstance(value, str) and value.strip().lower() == target:
|
|
@@ -60,22 +60,24 @@ def create_server(config=None):
|
|
|
60
60
|
return client().get_app(app_id)
|
|
61
61
|
|
|
62
62
|
@server.tool()
|
|
63
|
-
def find_app(name: str) -> dict:
|
|
64
|
-
"""Найти приложение по точному имени без учёта регистра; вернуть id и признак found.
|
|
65
|
-
|
|
63
|
+
def find_app(name: str, include_deleted: bool = False) -> dict:
|
|
64
|
+
"""Найти приложение по точному имени без учёта регистра; вернуть id и признак found.
|
|
65
|
+
|
|
66
|
+
Удалённые приложения (статус Deleted) по умолчанию пропускаются: на их
|
|
67
|
+
прежнем id get и deploy отвечают 404. include_deleted=True ищет среди
|
|
68
|
+
всех приложений, включая удалённые.
|
|
69
|
+
"""
|
|
70
|
+
app = client().find_app(name, include_deleted=include_deleted)
|
|
66
71
|
if app is None:
|
|
67
72
|
return {"id": None, "found": False}
|
|
68
73
|
return {"id": app.get("id"), "found": True, "application": app}
|
|
69
74
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
development_mode: bool = True,
|
|
77
|
-
) -> dict:
|
|
78
|
-
"""Создать приложение. При задании только project_id источником берётся последняя сборка проекта (создание из проекта целиком может дать пустой каркас)."""
|
|
75
|
+
def _create_app(name, project_id, version_id, space_id, development_mode):
|
|
76
|
+
"""Создать приложение (общая логика create_app и ensure_app).
|
|
77
|
+
|
|
78
|
+
Источник – version_id (id сборки) либо последняя сборка project_id
|
|
79
|
+
(создание из проекта целиком может дать пустой каркас).
|
|
80
|
+
"""
|
|
79
81
|
source_version_id = version_id
|
|
80
82
|
if not source_version_id:
|
|
81
83
|
if not project_id:
|
|
@@ -93,6 +95,39 @@ def create_server(config=None):
|
|
|
93
95
|
space_id=space_id or None,
|
|
94
96
|
)
|
|
95
97
|
|
|
98
|
+
@server.tool()
|
|
99
|
+
def create_app(
|
|
100
|
+
name: str,
|
|
101
|
+
project_id: str = "",
|
|
102
|
+
version_id: str = "",
|
|
103
|
+
space_id: str = "",
|
|
104
|
+
development_mode: bool = True,
|
|
105
|
+
) -> dict:
|
|
106
|
+
"""Создать приложение. При задании только project_id источником берётся последняя сборка проекта (создание из проекта целиком может дать пустой каркас)."""
|
|
107
|
+
return _create_app(name, project_id, version_id, space_id, development_mode)
|
|
108
|
+
|
|
109
|
+
@server.tool()
|
|
110
|
+
def ensure_app(
|
|
111
|
+
name: str,
|
|
112
|
+
project_id: str = "",
|
|
113
|
+
version_id: str = "",
|
|
114
|
+
space_id: str = "",
|
|
115
|
+
development_mode: bool = True,
|
|
116
|
+
) -> dict:
|
|
117
|
+
"""Идемпотентно создать приложение по имени, если его ещё нет.
|
|
118
|
+
|
|
119
|
+
Существующее приложение НЕ пересоздаётся: при наличии возвращается
|
|
120
|
+
{"id": ..., "created": false} без изменений (delete + create дали бы
|
|
121
|
+
новый URL и порвали внешние привязки – OIDC redirect и т.п.). Удалённые
|
|
122
|
+
приложения (статус Deleted) не в счёт. Параметры создания – как у
|
|
123
|
+
create_app; они действуют, только когда создание происходит.
|
|
124
|
+
"""
|
|
125
|
+
existing = client().find_app(name)
|
|
126
|
+
if existing is not None:
|
|
127
|
+
return {"id": existing.get("id"), "created": False}
|
|
128
|
+
card = _create_app(name, project_id, version_id, space_id, development_mode)
|
|
129
|
+
return {"id": (card or {}).get("id"), "created": True}
|
|
130
|
+
|
|
96
131
|
@server.tool()
|
|
97
132
|
def start_app(app_id: str) -> dict:
|
|
98
133
|
"""Запустить приложение."""
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: elemctl
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: CLI, MCP-сервер и библиотека для Console API v2 платформы 1С:Предприятие.Элемент (1cmycloud)
|
|
5
5
|
Author: KeyFire
|
|
6
6
|
License-Expression: MIT
|
|
@@ -112,6 +112,9 @@ elemctl apps list
|
|
|
112
112
|
# карточка приложения (статус, uri, фактическая версия проекта)
|
|
113
113
|
elemctl apps get <app-id>
|
|
114
114
|
|
|
115
|
+
# создать приложение, только если его ещё нет: {"id": ..., "created": true|false}
|
|
116
|
+
elemctl apps ensure acme-crm-dev --project-id <project-id> --latest-build --wait
|
|
117
|
+
|
|
115
118
|
# полный цикл деплоя из исходников с проверкой применения
|
|
116
119
|
elemctl deploy --app-id <app-id> --project-id <project-id> --project-dir acme/crm
|
|
117
120
|
|
|
@@ -184,6 +187,10 @@ Assembly.yaml # манифест: ProjectKind, Vendor, Name, Version,
|
|
|
184
187
|
`elemctl apps create <имя> --project-id <id> --latest-build` (MCP-инструмент
|
|
185
188
|
`create_app` подставляет последнюю сборку автоматически), а после создания -
|
|
186
189
|
`elemctl deploy`.
|
|
190
|
+
- Удалённые приложения остаются в списке платформы со статусом `Deleted` и
|
|
191
|
+
прежним `id`, на котором `apps get` и `deploy` отвечают 404. `apps find` и
|
|
192
|
+
`apps ensure` их пропускают; вернуть прежний поиск - `apps find
|
|
193
|
+
--include-deleted`.
|
|
187
194
|
- Приложение с неопубликованными правками в среде разработки платформа
|
|
188
195
|
удалить не даёт (HTTP 400 `FAILED_PRECONDITION`), принудительного удаления
|
|
189
196
|
в Console API нет - только через панель управления; elemctl подскажет это
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
"""Тесты CLI: локальные команды и коды возврата (без сети)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
import pytest
|
|
12
|
+
|
|
13
|
+
import elemctl
|
|
14
|
+
from elemctl import cli
|
|
15
|
+
from elemctl.errors import ApiError
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@pytest.fixture(autouse=True)
|
|
19
|
+
def clean_environment(monkeypatch, tmp_path):
|
|
20
|
+
"""Изолировать тесты от переменных окружения и .env разработчика."""
|
|
21
|
+
for key in (
|
|
22
|
+
"ELEMENT_BASE_URL",
|
|
23
|
+
"ELEMENT_CLIENT_ID",
|
|
24
|
+
"ELEMENT_CLIENT_SECRET",
|
|
25
|
+
"ELEMENT_APP_ID",
|
|
26
|
+
"ELEMENT_PROJECT_ID",
|
|
27
|
+
"ELEMENT_SPACE_ID",
|
|
28
|
+
):
|
|
29
|
+
monkeypatch.delenv(key, raising=False)
|
|
30
|
+
monkeypatch.chdir(tmp_path)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_version_flag(capsys):
|
|
34
|
+
with pytest.raises(SystemExit) as excinfo:
|
|
35
|
+
cli.main(["--version"])
|
|
36
|
+
assert excinfo.value.code == 0
|
|
37
|
+
assert f"elemctl {elemctl.__version__}" in capsys.readouterr().out
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_module_entry_point():
|
|
41
|
+
"""python -m elemctl – запасной путь для вызывающих без консольной точки входа в PATH."""
|
|
42
|
+
import_root = Path(elemctl.__file__).resolve().parent.parent
|
|
43
|
+
env = {**os.environ, "PYTHONPATH": str(import_root)}
|
|
44
|
+
result = subprocess.run([sys.executable, "-m", "elemctl", "--version"],
|
|
45
|
+
capture_output=True, text=True, env=env)
|
|
46
|
+
assert result.returncode == 0, result.stderr
|
|
47
|
+
assert f"elemctl {elemctl.__version__}" in result.stdout
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_build_command(project_factory, tmp_path, capsys):
|
|
51
|
+
project_dir = project_factory()
|
|
52
|
+
out_dir = tmp_path / "dist"
|
|
53
|
+
|
|
54
|
+
rc = cli.main(
|
|
55
|
+
["build", "--project-dir", str(project_dir), "--output", str(out_dir), "--branch", "", "--commit", ""]
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
assert rc == 0
|
|
59
|
+
payload = json.loads(capsys.readouterr().out)
|
|
60
|
+
archive = Path(payload["file"])
|
|
61
|
+
assert archive.exists()
|
|
62
|
+
assert archive.name == "crm 1.0-1.xasm"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_deploy_dry_run_builds_only(project_factory, tmp_path, capsys):
|
|
66
|
+
project_dir = project_factory()
|
|
67
|
+
rc = cli.main(
|
|
68
|
+
[
|
|
69
|
+
"deploy",
|
|
70
|
+
"--dry-run",
|
|
71
|
+
"--project-dir",
|
|
72
|
+
str(project_dir),
|
|
73
|
+
"--output",
|
|
74
|
+
str(tmp_path / "dist"),
|
|
75
|
+
]
|
|
76
|
+
)
|
|
77
|
+
assert rc == 0
|
|
78
|
+
payload = json.loads(capsys.readouterr().out)
|
|
79
|
+
assert Path(payload["file"]).exists()
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def test_apps_find_found_and_not_found(monkeypatch, capsys):
|
|
83
|
+
class FakeClient:
|
|
84
|
+
def find_app(self, name, *, include_deleted=False):
|
|
85
|
+
if name == "site-dev":
|
|
86
|
+
return {"id": "app-42", "display-name": "site-dev"}
|
|
87
|
+
return None
|
|
88
|
+
|
|
89
|
+
monkeypatch.setattr(cli, "make_client", lambda config: FakeClient())
|
|
90
|
+
|
|
91
|
+
rc = cli.main(["apps", "find", "site-dev"])
|
|
92
|
+
assert rc == 0
|
|
93
|
+
assert json.loads(capsys.readouterr().out) == {"id": "app-42", "found": True}
|
|
94
|
+
|
|
95
|
+
# Отсутствие приложения – штатный ответ: код возврата 0, признак несёт поле found.
|
|
96
|
+
rc = cli.main(["apps", "find", "нет-такого"])
|
|
97
|
+
assert rc == 0
|
|
98
|
+
assert json.loads(capsys.readouterr().out) == {"id": None, "found": False}
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def test_apps_find_request_failure_is_an_error(monkeypatch, capsys):
|
|
102
|
+
"""Сбой запроса отличим от "не найдено": ненулевой код и error в stderr."""
|
|
103
|
+
|
|
104
|
+
class FailingClient:
|
|
105
|
+
def find_app(self, name, *, include_deleted=False):
|
|
106
|
+
raise ApiError("нет доступа", status=403)
|
|
107
|
+
|
|
108
|
+
monkeypatch.setattr(cli, "make_client", lambda config: FailingClient())
|
|
109
|
+
|
|
110
|
+
rc = cli.main(["apps", "find", "site-dev"])
|
|
111
|
+
assert rc == 1
|
|
112
|
+
captured = capsys.readouterr()
|
|
113
|
+
assert captured.out == ""
|
|
114
|
+
assert "error" in json.loads(captured.err)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def test_apps_find_skips_deleted_unless_flag(monkeypatch, capsys):
|
|
118
|
+
"""По умолчанию удалённое приложение не находится; --include-deleted его возвращает."""
|
|
119
|
+
|
|
120
|
+
class FakeClient:
|
|
121
|
+
def find_app(self, name, *, include_deleted=False):
|
|
122
|
+
# Приложение есть в списке платформы, но удалено: без флага его нет.
|
|
123
|
+
if name == "site-old" and include_deleted:
|
|
124
|
+
return {"id": "app-del", "display-name": "site-old", "status": "Deleted"}
|
|
125
|
+
return None
|
|
126
|
+
|
|
127
|
+
monkeypatch.setattr(cli, "make_client", lambda config: FakeClient())
|
|
128
|
+
|
|
129
|
+
rc = cli.main(["apps", "find", "site-old"])
|
|
130
|
+
assert rc == 0
|
|
131
|
+
assert json.loads(capsys.readouterr().out) == {"id": None, "found": False}
|
|
132
|
+
|
|
133
|
+
rc = cli.main(["apps", "find", "site-old", "--include-deleted"])
|
|
134
|
+
assert rc == 0
|
|
135
|
+
assert json.loads(capsys.readouterr().out) == {"id": "app-del", "found": True}
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def test_apps_ensure_existing_returns_created_false_without_creating(monkeypatch, capsys):
|
|
139
|
+
"""Существующее приложение не пересоздаётся: created=false, create_app не вызывается."""
|
|
140
|
+
|
|
141
|
+
class FakeClient:
|
|
142
|
+
def find_app(self, name, *, include_deleted=False):
|
|
143
|
+
return {"id": "app-7", "display-name": name}
|
|
144
|
+
|
|
145
|
+
def create_app(self, *args, **kwargs):
|
|
146
|
+
raise AssertionError("создание не должно вызываться для существующего приложения")
|
|
147
|
+
|
|
148
|
+
monkeypatch.setattr(cli, "make_client", lambda config: FakeClient())
|
|
149
|
+
|
|
150
|
+
rc = cli.main(["apps", "ensure", "site-dev", "--version-id", "asm-1"])
|
|
151
|
+
assert rc == 0
|
|
152
|
+
assert json.loads(capsys.readouterr().out) == {"id": "app-7", "created": False}
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def test_apps_ensure_missing_creates_and_returns_created_true(monkeypatch, capsys):
|
|
156
|
+
"""Отсутствующее приложение создаётся: created=true, источником идёт указанная сборка."""
|
|
157
|
+
|
|
158
|
+
class FakeClient:
|
|
159
|
+
def find_app(self, name, *, include_deleted=False):
|
|
160
|
+
return None
|
|
161
|
+
|
|
162
|
+
def create_app(
|
|
163
|
+
self,
|
|
164
|
+
display_name,
|
|
165
|
+
*,
|
|
166
|
+
project_version_id=None,
|
|
167
|
+
image_id=None,
|
|
168
|
+
development_mode=True,
|
|
169
|
+
space_id=None,
|
|
170
|
+
technology_version=None,
|
|
171
|
+
):
|
|
172
|
+
assert display_name == "site-new"
|
|
173
|
+
assert project_version_id == "asm-9"
|
|
174
|
+
assert image_id is None
|
|
175
|
+
return {"id": "app-new", "display-name": display_name}
|
|
176
|
+
|
|
177
|
+
monkeypatch.setattr(cli, "make_client", lambda config: FakeClient())
|
|
178
|
+
|
|
179
|
+
rc = cli.main(["apps", "ensure", "site-new", "--version-id", "asm-9"])
|
|
180
|
+
assert rc == 0
|
|
181
|
+
assert json.loads(capsys.readouterr().out) == {"id": "app-new", "created": True}
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def test_apps_ensure_request_failure_is_an_error(monkeypatch, capsys):
|
|
185
|
+
"""Сбой запроса в ensure: код возврата 1, пустой stdout, error в stderr."""
|
|
186
|
+
|
|
187
|
+
class FailingClient:
|
|
188
|
+
def find_app(self, name, *, include_deleted=False):
|
|
189
|
+
raise ApiError("нет доступа", status=403)
|
|
190
|
+
|
|
191
|
+
monkeypatch.setattr(cli, "make_client", lambda config: FailingClient())
|
|
192
|
+
|
|
193
|
+
rc = cli.main(["apps", "ensure", "site-dev"])
|
|
194
|
+
assert rc == 1
|
|
195
|
+
captured = capsys.readouterr()
|
|
196
|
+
assert captured.out == ""
|
|
197
|
+
assert "error" in json.loads(captured.err)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def test_error_is_json_on_stderr(capsys):
|
|
201
|
+
# app-id не задан ни аргументом, ни конфигурацией.
|
|
202
|
+
rc = cli.main(["apps", "get"])
|
|
203
|
+
assert rc == 1
|
|
204
|
+
captured = capsys.readouterr()
|
|
205
|
+
payload = json.loads(captured.err)
|
|
206
|
+
assert "error" in payload
|
|
207
|
+
assert "app-id" in payload["error"]
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def test_deploy_exit_code_reflects_ok(monkeypatch, capsys, project_factory, tmp_path):
|
|
211
|
+
from tests.test_deploy import FakeDeployClient
|
|
212
|
+
|
|
213
|
+
# Успех: применённая версия совпала.
|
|
214
|
+
monkeypatch.setattr(
|
|
215
|
+
cli, "make_client", lambda config: FakeDeployClient(applied_version="1.0-1")
|
|
216
|
+
)
|
|
217
|
+
rc = cli.main(
|
|
218
|
+
[
|
|
219
|
+
"deploy",
|
|
220
|
+
"--app-id",
|
|
221
|
+
"app-1",
|
|
222
|
+
"--project-id",
|
|
223
|
+
"proj-1",
|
|
224
|
+
"--project-dir",
|
|
225
|
+
str(project_factory(repo_name="repo-ok")),
|
|
226
|
+
"--output",
|
|
227
|
+
str(tmp_path / "d1"),
|
|
228
|
+
"--build-version",
|
|
229
|
+
"1.0-1",
|
|
230
|
+
]
|
|
231
|
+
)
|
|
232
|
+
captured = capsys.readouterr()
|
|
233
|
+
assert rc == 0
|
|
234
|
+
assert json.loads(captured.out)["ok"] is True
|
|
235
|
+
|
|
236
|
+
# Откат: версия не совпала – код возврата 1.
|
|
237
|
+
monkeypatch.setattr(
|
|
238
|
+
cli, "make_client", lambda config: FakeDeployClient(applied_version="1.0-0")
|
|
239
|
+
)
|
|
240
|
+
rc = cli.main(
|
|
241
|
+
[
|
|
242
|
+
"deploy",
|
|
243
|
+
"--app-id",
|
|
244
|
+
"app-1",
|
|
245
|
+
"--project-id",
|
|
246
|
+
"proj-1",
|
|
247
|
+
"--project-dir",
|
|
248
|
+
str(project_factory(repo_name="repo-fail")),
|
|
249
|
+
"--output",
|
|
250
|
+
str(tmp_path / "d2"),
|
|
251
|
+
"--build-version",
|
|
252
|
+
"1.0-1",
|
|
253
|
+
]
|
|
254
|
+
)
|
|
255
|
+
captured = capsys.readouterr()
|
|
256
|
+
assert rc == 1
|
|
257
|
+
report = json.loads(captured.out)
|
|
258
|
+
assert report["ok"] is False
|
|
259
|
+
assert report["applied"] is False
|
|
@@ -63,6 +63,34 @@ def test_find_app_exact_case_insensitive(api):
|
|
|
63
63
|
assert client.find_app("nope") is None
|
|
64
64
|
|
|
65
65
|
|
|
66
|
+
def test_find_app_skips_deleted(api):
|
|
67
|
+
client, transport = api
|
|
68
|
+
transport.add(
|
|
69
|
+
"GET",
|
|
70
|
+
f"{API}/applications",
|
|
71
|
+
[
|
|
72
|
+
{"id": "old", "display-name": "site", "status": "Deleted"},
|
|
73
|
+
{"id": "live", "display-name": "site", "status": "Running"},
|
|
74
|
+
],
|
|
75
|
+
)
|
|
76
|
+
# Удалённая карточка пропускается – находится живое приложение с тем же именем.
|
|
77
|
+
assert client.find_app("site")["id"] == "live"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def test_find_app_include_deleted_returns_deleted(api):
|
|
81
|
+
client, transport = api
|
|
82
|
+
# Единственный ответ повторяется на оба вызова find_app.
|
|
83
|
+
transport.add(
|
|
84
|
+
"GET",
|
|
85
|
+
f"{API}/applications",
|
|
86
|
+
[{"id": "old", "display-name": "site-old", "status": "DELETED"}],
|
|
87
|
+
)
|
|
88
|
+
# По умолчанию удалённое не находится...
|
|
89
|
+
assert client.find_app("site-old") is None
|
|
90
|
+
# ...а с include_deleted=True возвращается прежнее поведение (сравнение статуса без учёта регистра).
|
|
91
|
+
assert client.find_app("site-old", include_deleted=True)["id"] == "old"
|
|
92
|
+
|
|
93
|
+
|
|
66
94
|
def test_delete_failed_precondition_hint(api):
|
|
67
95
|
client, transport = api
|
|
68
96
|
transport.add(
|
|
@@ -15,6 +15,7 @@ EXPECTED_TOOLS = {
|
|
|
15
15
|
"get_app",
|
|
16
16
|
"find_app",
|
|
17
17
|
"create_app",
|
|
18
|
+
"ensure_app",
|
|
18
19
|
"start_app",
|
|
19
20
|
"stop_app",
|
|
20
21
|
"delete_app",
|
|
@@ -49,3 +50,20 @@ def test_delete_app_docstring_warns():
|
|
|
49
50
|
tools = asyncio.run(server.list_tools())
|
|
50
51
|
delete_tool = next(tool for tool in tools if tool.name == "delete_app")
|
|
51
52
|
assert "URL" in (delete_tool.description or "")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_ensure_app_docstring_states_no_recreate():
|
|
56
|
+
server = create_server()
|
|
57
|
+
tools = asyncio.run(server.list_tools())
|
|
58
|
+
ensure_tool = next(tool for tool in tools if tool.name == "ensure_app")
|
|
59
|
+
description = ensure_tool.description or ""
|
|
60
|
+
assert "пересозда" in description # существующее приложение НЕ пересоздаётся
|
|
61
|
+
assert "created" in description
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_find_app_exposes_include_deleted():
|
|
65
|
+
server = create_server()
|
|
66
|
+
tools = asyncio.run(server.list_tools())
|
|
67
|
+
find_tool = next(tool for tool in tools if tool.name == "find_app")
|
|
68
|
+
properties = (find_tool.inputSchema or {}).get("properties") or {}
|
|
69
|
+
assert "include_deleted" in properties
|
elemctl-0.2.0/tests/test_cli.py
DELETED
|
@@ -1,144 +0,0 @@
|
|
|
1
|
-
"""Тесты CLI: локальные команды и коды возврата (без сети)."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
import json
|
|
6
|
-
from pathlib import Path
|
|
7
|
-
|
|
8
|
-
import pytest
|
|
9
|
-
|
|
10
|
-
from elemctl import cli
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
@pytest.fixture(autouse=True)
|
|
14
|
-
def clean_environment(monkeypatch, tmp_path):
|
|
15
|
-
"""Изолировать тесты от переменных окружения и .env разработчика."""
|
|
16
|
-
for key in (
|
|
17
|
-
"ELEMENT_BASE_URL",
|
|
18
|
-
"ELEMENT_CLIENT_ID",
|
|
19
|
-
"ELEMENT_CLIENT_SECRET",
|
|
20
|
-
"ELEMENT_APP_ID",
|
|
21
|
-
"ELEMENT_PROJECT_ID",
|
|
22
|
-
"ELEMENT_SPACE_ID",
|
|
23
|
-
):
|
|
24
|
-
monkeypatch.delenv(key, raising=False)
|
|
25
|
-
monkeypatch.chdir(tmp_path)
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
def test_version_flag(capsys):
|
|
29
|
-
with pytest.raises(SystemExit) as excinfo:
|
|
30
|
-
cli.main(["--version"])
|
|
31
|
-
assert excinfo.value.code == 0
|
|
32
|
-
assert "elemctl 0.2.0" in capsys.readouterr().out
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
def test_build_command(project_factory, tmp_path, capsys):
|
|
36
|
-
project_dir = project_factory()
|
|
37
|
-
out_dir = tmp_path / "dist"
|
|
38
|
-
|
|
39
|
-
rc = cli.main(
|
|
40
|
-
["build", "--project-dir", str(project_dir), "--output", str(out_dir), "--branch", "", "--commit", ""]
|
|
41
|
-
)
|
|
42
|
-
|
|
43
|
-
assert rc == 0
|
|
44
|
-
payload = json.loads(capsys.readouterr().out)
|
|
45
|
-
archive = Path(payload["file"])
|
|
46
|
-
assert archive.exists()
|
|
47
|
-
assert archive.name == "crm 1.0-1.xasm"
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
def test_deploy_dry_run_builds_only(project_factory, tmp_path, capsys):
|
|
51
|
-
project_dir = project_factory()
|
|
52
|
-
rc = cli.main(
|
|
53
|
-
[
|
|
54
|
-
"deploy",
|
|
55
|
-
"--dry-run",
|
|
56
|
-
"--project-dir",
|
|
57
|
-
str(project_dir),
|
|
58
|
-
"--output",
|
|
59
|
-
str(tmp_path / "dist"),
|
|
60
|
-
]
|
|
61
|
-
)
|
|
62
|
-
assert rc == 0
|
|
63
|
-
payload = json.loads(capsys.readouterr().out)
|
|
64
|
-
assert Path(payload["file"]).exists()
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
def test_apps_find_found_and_not_found(monkeypatch, capsys):
|
|
68
|
-
class FakeClient:
|
|
69
|
-
def find_app(self, name):
|
|
70
|
-
if name == "site-dev":
|
|
71
|
-
return {"id": "app-42", "display-name": "site-dev"}
|
|
72
|
-
return None
|
|
73
|
-
|
|
74
|
-
monkeypatch.setattr(cli, "make_client", lambda config: FakeClient())
|
|
75
|
-
|
|
76
|
-
rc = cli.main(["apps", "find", "site-dev"])
|
|
77
|
-
assert rc == 0
|
|
78
|
-
assert json.loads(capsys.readouterr().out) == {"id": "app-42", "found": True}
|
|
79
|
-
|
|
80
|
-
rc = cli.main(["apps", "find", "нет-такого"])
|
|
81
|
-
assert rc == 1
|
|
82
|
-
assert json.loads(capsys.readouterr().out) == {"id": None, "found": False}
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
def test_error_is_json_on_stderr(capsys):
|
|
86
|
-
# app-id не задан ни аргументом, ни конфигурацией.
|
|
87
|
-
rc = cli.main(["apps", "get"])
|
|
88
|
-
assert rc == 1
|
|
89
|
-
captured = capsys.readouterr()
|
|
90
|
-
payload = json.loads(captured.err)
|
|
91
|
-
assert "error" in payload
|
|
92
|
-
assert "app-id" in payload["error"]
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
def test_deploy_exit_code_reflects_ok(monkeypatch, capsys, project_factory, tmp_path):
|
|
96
|
-
from tests.test_deploy import FakeDeployClient
|
|
97
|
-
|
|
98
|
-
# Успех: применённая версия совпала.
|
|
99
|
-
monkeypatch.setattr(
|
|
100
|
-
cli, "make_client", lambda config: FakeDeployClient(applied_version="1.0-1")
|
|
101
|
-
)
|
|
102
|
-
rc = cli.main(
|
|
103
|
-
[
|
|
104
|
-
"deploy",
|
|
105
|
-
"--app-id",
|
|
106
|
-
"app-1",
|
|
107
|
-
"--project-id",
|
|
108
|
-
"proj-1",
|
|
109
|
-
"--project-dir",
|
|
110
|
-
str(project_factory(repo_name="repo-ok")),
|
|
111
|
-
"--output",
|
|
112
|
-
str(tmp_path / "d1"),
|
|
113
|
-
"--build-version",
|
|
114
|
-
"1.0-1",
|
|
115
|
-
]
|
|
116
|
-
)
|
|
117
|
-
captured = capsys.readouterr()
|
|
118
|
-
assert rc == 0
|
|
119
|
-
assert json.loads(captured.out)["ok"] is True
|
|
120
|
-
|
|
121
|
-
# Откат: версия не совпала – код возврата 1.
|
|
122
|
-
monkeypatch.setattr(
|
|
123
|
-
cli, "make_client", lambda config: FakeDeployClient(applied_version="1.0-0")
|
|
124
|
-
)
|
|
125
|
-
rc = cli.main(
|
|
126
|
-
[
|
|
127
|
-
"deploy",
|
|
128
|
-
"--app-id",
|
|
129
|
-
"app-1",
|
|
130
|
-
"--project-id",
|
|
131
|
-
"proj-1",
|
|
132
|
-
"--project-dir",
|
|
133
|
-
str(project_factory(repo_name="repo-fail")),
|
|
134
|
-
"--output",
|
|
135
|
-
str(tmp_path / "d2"),
|
|
136
|
-
"--build-version",
|
|
137
|
-
"1.0-1",
|
|
138
|
-
]
|
|
139
|
-
)
|
|
140
|
-
captured = capsys.readouterr()
|
|
141
|
-
assert rc == 1
|
|
142
|
-
report = json.loads(captured.out)
|
|
143
|
-
assert report["ok"] is False
|
|
144
|
-
assert report["applied"] is False
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|