Initial commit: omnichannel-mcp — MCP-сервер управления платформой Omnichannel
This commit is contained in:
@@ -0,0 +1,16 @@
|
|||||||
|
# Секреты модуля omnichannel-mcp. Реальные значения — в .env (gitignored)
|
||||||
|
# или в config.local.json рядом с config.json.
|
||||||
|
#
|
||||||
|
# Основная учётная запись (право записи):
|
||||||
|
OMNI_USER=admin
|
||||||
|
OMNI_PASSWORD=
|
||||||
|
|
||||||
|
# Необязательная учётная запись только для чтения (least-privilege):
|
||||||
|
# OMNI_RO_USER=
|
||||||
|
# OMNI_RO_PASSWORD=
|
||||||
|
|
||||||
|
# Необязательный GitLab-токен для download_release:
|
||||||
|
# OMNI_GITLAB_TOKEN=
|
||||||
|
|
||||||
|
# Уровень логов модуля (debug|info|warn|error):
|
||||||
|
OMNI_LOG_LEVEL=info
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
name: ci
|
||||||
|
|
||||||
|
# Гейт качества на PR и push в main: vet, тесты с race-детектором, линт,
|
||||||
|
# gofmt и проверка tidy. Внутренний CI (Gitea Actions).
|
||||||
|
#
|
||||||
|
# ВАЖНО: job-контейнер golang:1.27 не содержит Node.js, поэтому
|
||||||
|
# actions/checkout не работает — checkout делается вручную через git.
|
||||||
|
# Приватный модуль forge-toolkit лежит на том же Gitea, доступ — по токену.
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: backend
|
||||||
|
container: golang:1.27
|
||||||
|
steps:
|
||||||
|
- name: checkout
|
||||||
|
run: |
|
||||||
|
git clone "https://x-access-token:${{ github.token }}@git.totmin.ru/${{ github.repository }}.git" .
|
||||||
|
git checkout -q "${{ github.sha }}"
|
||||||
|
- name: configure private modules
|
||||||
|
run: |
|
||||||
|
git config --global url."https://x-access-token:${{ github.token }}@git.totmin.ru/".insteadOf "https://git.totmin.ru/"
|
||||||
|
go env -w GOPRIVATE=git.totmin.ru
|
||||||
|
- name: vet
|
||||||
|
run: go vet ./...
|
||||||
|
- name: test (race)
|
||||||
|
run: go test -race ./... -count=1
|
||||||
|
|
||||||
|
lint:
|
||||||
|
runs-on: backend
|
||||||
|
container: golang:1.27
|
||||||
|
steps:
|
||||||
|
- name: checkout
|
||||||
|
run: |
|
||||||
|
git clone "https://x-access-token:${{ github.token }}@git.totmin.ru/${{ github.repository }}.git" .
|
||||||
|
git checkout -q "${{ github.sha }}"
|
||||||
|
- name: configure private modules
|
||||||
|
run: |
|
||||||
|
git config --global url."https://x-access-token:${{ github.token }}@git.totmin.ru/".insteadOf "https://git.totmin.ru/"
|
||||||
|
go env -w GOPRIVATE=git.totmin.ru
|
||||||
|
- name: install golangci-lint
|
||||||
|
run: go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.13.2
|
||||||
|
- name: lint
|
||||||
|
run: $(go env GOPATH)/bin/golangci-lint run ./...
|
||||||
|
- name: gofmt
|
||||||
|
run: |
|
||||||
|
unformatted=$(gofmt -l .)
|
||||||
|
[ -z "$unformatted" ] || { echo "gofmt: требуется форматирование: $unformatted"; exit 1; }
|
||||||
|
- name: go mod tidy check
|
||||||
|
run: go mod tidy -diff
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
# Секреты и локальные данные — не коммитим.
|
||||||
|
config.local.json
|
||||||
|
*.local.json
|
||||||
|
.env
|
||||||
|
|
||||||
|
# Бинарник модуля и кэш Python.
|
||||||
|
/omnichannel-mcp
|
||||||
|
__pycache__/
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
Все заметные изменения проекта описываются здесь.
|
||||||
|
Формат — [Keep a Changelog](https://keepachangelog.com/ru/1.1.0/),
|
||||||
|
версионирование — [Semantic Versioning](https://semver.org/lang/ru/).
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.1.0] — 2026-10-07
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- MCP-сервер `omnichannel-mcp` для управления платформой Omnichannel через API
|
||||||
|
`config_server` 1.1.0+ (36 инструментов: наблюдение, конфигурация, жизненный
|
||||||
|
цикл, релиз, обслуживание).
|
||||||
|
- Режим «только чтение» и отдельная read-only учётная запись.
|
||||||
|
- Подтверждение разрушительных операций (`confirm`).
|
||||||
|
- Мультисерверная конфигурация, `${VAR}` и `config.local.json` для секретов.
|
||||||
|
- Capability-probe (`server_info`) с определением версии API.
|
||||||
|
- Асинхронная работа с задачами (`get_task`/`wait`) с ограничением по времени.
|
||||||
|
- Файловый jail для обновления фронта (`front_roots`).
|
||||||
|
- Документация (`docs/`), примеры конфигураций и промптов, воспроизводимый
|
||||||
|
демо-стенд.
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# Разработка
|
||||||
|
|
||||||
|
Внутренний проект. Ниже — короткие правила, чтобы изменения проходили ревью
|
||||||
|
быстро.
|
||||||
|
|
||||||
|
## Окружение
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export GOPRIVATE=git.totmin.ru
|
||||||
|
make build # собрать бинарник
|
||||||
|
make ci # build + test(race) + lint + tidy-check (то же, что CI)
|
||||||
|
```
|
||||||
|
|
||||||
|
Требуется Go 1.27+ и доступ к приватному модулю `forge-toolkit` на
|
||||||
|
`git.totmin.ru` (для него задаётся `GOPRIVATE`).
|
||||||
|
|
||||||
|
## Локальный стенд
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make demo # поднять config_server v1.1.0 и прогнать сквозной сценарий
|
||||||
|
make demo-down # остановить и очистить
|
||||||
|
```
|
||||||
|
|
||||||
|
## Принципы кода
|
||||||
|
|
||||||
|
- Домен (`internal/configserver`) не зависит от MCP; MCP-слой
|
||||||
|
(`internal/tools`) — только трансляция запрос/ответ.
|
||||||
|
- Простота и читаемость важнее «умности»; код документируется по существу.
|
||||||
|
- Новый инструмент: схема с описанием, обработчик, тест, запись в
|
||||||
|
`docs/tools.md`.
|
||||||
|
- Секреты только из переменных окружения или `config.local.json`
|
||||||
|
(в `.gitignore`), никогда — в `config.json` и коде.
|
||||||
|
|
||||||
|
## Перед коммитом
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make ci
|
||||||
|
```
|
||||||
|
|
||||||
|
Проверки: `gofmt`, `go vet`, `go test -race`, `golangci-lint`, `go mod tidy`.
|
||||||
|
CI (`.gitea/workflows/ci.yml`) запускается на PR и push в `main`.
|
||||||
|
|
||||||
|
## Коммиты и PR
|
||||||
|
|
||||||
|
- Осмысленные сообщения; в PR — что, зачем, как проверяли, влияние на
|
||||||
|
безопасность.
|
||||||
|
- Пользовательские изменения отмечайте в `CHANGELOG.md`.
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
|
||||||
|
Apache License
|
||||||
|
Version 2.0, January 2004
|
||||||
|
http://www.apache.org/licenses/
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||||
|
|
||||||
|
1. Definitions.
|
||||||
|
|
||||||
|
"License" shall mean the terms and conditions for use, reproduction,
|
||||||
|
and distribution as defined by Sections 1 through 9 of this document.
|
||||||
|
|
||||||
|
"Licensor" shall mean the copyright owner or entity authorized by
|
||||||
|
the copyright owner that is granting the License.
|
||||||
|
|
||||||
|
"Legal Entity" shall mean the union of the acting entity and all
|
||||||
|
other entities that control, are controlled by, or are under common
|
||||||
|
control with that entity. For the purposes of this definition,
|
||||||
|
"control" means (i) the power, direct or indirect, to cause the
|
||||||
|
direction or management of such entity, whether by contract or
|
||||||
|
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||||
|
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||||
|
|
||||||
|
"You" (or "Your") shall mean an individual or Legal Entity
|
||||||
|
exercising permissions granted by this License.
|
||||||
|
|
||||||
|
"Source" form shall mean the preferred form for making modifications,
|
||||||
|
including but not limited to software source code, documentation
|
||||||
|
source, and configuration files.
|
||||||
|
|
||||||
|
"Object" form shall mean any form resulting from mechanical
|
||||||
|
transformation or translation of a Source form, including but
|
||||||
|
not limited to compiled object code, generated documentation,
|
||||||
|
and conversions to other media types.
|
||||||
|
|
||||||
|
"Work" shall mean the work of authorship, whether in Source or
|
||||||
|
Object form, made available under the License, as indicated by a
|
||||||
|
copyright notice that is included in or attached to the work
|
||||||
|
(an example is provided in the Appendix below).
|
||||||
|
|
||||||
|
"Derivative Works" shall mean any work, whether in Source or Object
|
||||||
|
form, that is based on (or derived from) the Work and for which the
|
||||||
|
editorial revisions, annotations, elaborations, or other modifications
|
||||||
|
represent, as a whole, an original work of authorship. For the purposes
|
||||||
|
of this License, Derivative Works shall not include works that remain
|
||||||
|
separable from, or merely link (or bind by name) to the interfaces of,
|
||||||
|
the Work and Derivative Works thereof.
|
||||||
|
|
||||||
|
"Contribution" shall mean any work of authorship, including
|
||||||
|
the original version of the Work and any modifications or additions
|
||||||
|
to that Work or Derivative Works thereof, that is intentionally
|
||||||
|
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||||
|
or by an individual or Legal Entity authorized to submit on behalf of
|
||||||
|
the copyright owner. For the purposes of this definition, "submitted"
|
||||||
|
means any form of electronic, verbal, or written communication sent
|
||||||
|
to the Licensor or its representatives, including but not limited to
|
||||||
|
communication on electronic mailing lists, source code control systems,
|
||||||
|
and issue tracking systems that are managed by, or on behalf of, the
|
||||||
|
Licensor for the purpose of discussing and improving the Work, but
|
||||||
|
excluding communication that is conspicuously marked or otherwise
|
||||||
|
designated in writing by the copyright owner as "Not a Contribution."
|
||||||
|
|
||||||
|
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||||
|
on behalf of whom a Contribution has been received by Licensor and
|
||||||
|
subsequently incorporated within the Work.
|
||||||
|
|
||||||
|
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
copyright license to reproduce, prepare Derivative Works of,
|
||||||
|
publicly display, publicly perform, sublicense, and distribute the
|
||||||
|
Work and such Derivative Works in Source or Object form.
|
||||||
|
|
||||||
|
3. Grant of Patent License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
(except as stated in this section) patent license to make, have made,
|
||||||
|
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||||
|
where such license applies only to those patent claims licensable
|
||||||
|
by such Contributor that are necessarily infringed by their
|
||||||
|
Contribution(s) alone or by combination of their Contribution(s)
|
||||||
|
with the Work to which such Contribution(s) was submitted. If You
|
||||||
|
institute patent litigation against any entity (including a
|
||||||
|
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||||
|
or a Contribution incorporated within the Work constitutes direct
|
||||||
|
or contributory patent infringement, then any patent licenses
|
||||||
|
granted to You under this License for that Work shall terminate
|
||||||
|
as of the date such litigation is filed.
|
||||||
|
|
||||||
|
4. Redistribution. You may reproduce and distribute copies of the
|
||||||
|
Work or Derivative Works thereof in any medium, with or without
|
||||||
|
modifications, and in Source or Object form, provided that You
|
||||||
|
meet the following conditions:
|
||||||
|
|
||||||
|
(a) You must give any other recipients of the Work or
|
||||||
|
Derivative Works a copy of this License; and
|
||||||
|
|
||||||
|
(b) You must cause any modified files to carry prominent notices
|
||||||
|
stating that You changed the files; and
|
||||||
|
|
||||||
|
(c) You must retain, in the Source form of any Derivative Works
|
||||||
|
that You distribute, all copyright, patent, trademark, and
|
||||||
|
attribution notices from the Source form of the Work,
|
||||||
|
excluding those notices that do not pertain to any part of
|
||||||
|
the Derivative Works; and
|
||||||
|
|
||||||
|
(d) If the Work includes a "NOTICE" text file as part of its
|
||||||
|
distribution, then any Derivative Works that You distribute must
|
||||||
|
include a readable copy of the attribution notices contained
|
||||||
|
within such NOTICE file, excluding those notices that do not
|
||||||
|
pertain to any part of the Derivative Works, in at least one
|
||||||
|
of the following places: within a NOTICE text file distributed
|
||||||
|
as part of the Derivative Works; within the Source form or
|
||||||
|
documentation, if provided along with the Derivative Works; or,
|
||||||
|
within a display generated by the Derivative Works, if and
|
||||||
|
wherever such third-party notices normally appear. The contents
|
||||||
|
of the NOTICE file are for informational purposes only and
|
||||||
|
do not modify the License. You may add Your own attribution
|
||||||
|
notices within Derivative Works that You distribute, alongside
|
||||||
|
or as an addendum to the NOTICE text from the Work, provided
|
||||||
|
that such additional attribution notices cannot be construed
|
||||||
|
as modifying the License.
|
||||||
|
|
||||||
|
You may add Your own copyright statement to Your modifications and
|
||||||
|
may provide additional or different license terms and conditions
|
||||||
|
for use, reproduction, or distribution of Your modifications, or
|
||||||
|
for any such Derivative Works as a whole, provided Your use,
|
||||||
|
reproduction, and distribution of the Work otherwise complies with
|
||||||
|
the conditions stated in this License.
|
||||||
|
|
||||||
|
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||||
|
any Contribution intentionally submitted for inclusion in the Work
|
||||||
|
by You to the Licensor shall be under the terms and conditions of
|
||||||
|
this License, without any additional terms or conditions.
|
||||||
|
Notwithstanding the above, nothing herein shall supersede or modify
|
||||||
|
the terms of any separate license agreement you may have executed
|
||||||
|
with Licensor regarding such Contributions.
|
||||||
|
|
||||||
|
6. Trademarks. This License does not grant permission to use the trade
|
||||||
|
names, trademarks, service marks, or product names of the Licensor,
|
||||||
|
except as required for reasonable and customary use in describing the
|
||||||
|
origin of the Work and reproducing the content of the NOTICE file.
|
||||||
|
|
||||||
|
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||||
|
agreed to in writing, Licensor provides the Work (and each
|
||||||
|
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||||
|
implied, including, without limitation, any warranties or conditions
|
||||||
|
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||||
|
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||||
|
appropriateness of using or redistributing the Work and assume any
|
||||||
|
risks associated with Your exercise of permissions under this License.
|
||||||
|
|
||||||
|
8. Limitation of Liability. In no event and under no legal theory,
|
||||||
|
whether in tort (including negligence), contract, or otherwise,
|
||||||
|
unless required by applicable law (such as deliberate and grossly
|
||||||
|
negligent acts) or agreed to in writing, shall any Contributor be
|
||||||
|
liable to You for damages, including any direct, indirect, special,
|
||||||
|
incidental, or consequential damages of any character arising as a
|
||||||
|
result of this License or out of the use or inability to use the
|
||||||
|
Work (including but not limited to damages for loss of goodwill,
|
||||||
|
work stoppage, computer failure or malfunction, or any and all
|
||||||
|
other commercial damages or losses), even if such Contributor
|
||||||
|
has been advised of the possibility of such damages.
|
||||||
|
|
||||||
|
9. Accepting Warranty or Additional Liability. While redistributing
|
||||||
|
the Work or Derivative Works thereof, You may choose to offer,
|
||||||
|
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||||
|
or other liability obligations and/or rights consistent with this
|
||||||
|
License. However, in accepting such obligations, You may act only
|
||||||
|
on Your own behalf and on Your sole responsibility, not on behalf
|
||||||
|
of any other Contributor, and only if You agree to indemnify,
|
||||||
|
defend, and hold each Contributor harmless for any liability
|
||||||
|
incurred by, or claims asserted against, such Contributor by reason
|
||||||
|
of your accepting any such warranty or additional liability.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
APPENDIX: How to apply the Apache License to your work.
|
||||||
|
|
||||||
|
To apply the Apache License to your work, attach the following
|
||||||
|
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||||
|
replaced with your own identifying information. (Don't include
|
||||||
|
the brackets!) The text should be enclosed in the appropriate
|
||||||
|
comment syntax for the file format. We also recommend that a
|
||||||
|
file or class name and description of purpose be included on the
|
||||||
|
same "printed page" as the copyright notice for easier
|
||||||
|
identification within third-party archives.
|
||||||
|
|
||||||
|
Copyright [yyyy] [name of copyright owner]
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
# omnichannel-mcp — сборка, тесты, линт.
|
||||||
|
GO ?= go
|
||||||
|
GOLANGCI_LINT ?= golangci-lint
|
||||||
|
|
||||||
|
.PHONY: build test fmt lint tidy tidy-check ci demo e2e demo-down
|
||||||
|
|
||||||
|
# Бинарник модуля.
|
||||||
|
build:
|
||||||
|
$(GO) build -o omnichannel-mcp .
|
||||||
|
|
||||||
|
# Тесты с race-детектором (основной гейт).
|
||||||
|
test:
|
||||||
|
$(GO) test -race ./... -count=1
|
||||||
|
|
||||||
|
# Форматирование.
|
||||||
|
fmt:
|
||||||
|
gofmt -w .
|
||||||
|
|
||||||
|
# Статический анализ + проверка форматирования.
|
||||||
|
lint:
|
||||||
|
$(GOLANGCI_LINT) run ./...
|
||||||
|
@unformatted=$$(gofmt -l .); \
|
||||||
|
if [ -n "$$unformatted" ]; then echo "gofmt: нужно форматирование: $$unformatted"; exit 1; fi
|
||||||
|
|
||||||
|
tidy:
|
||||||
|
$(GO) mod tidy
|
||||||
|
|
||||||
|
# Проверка, что go.mod/go.sum в tidy-состоянии.
|
||||||
|
tidy-check:
|
||||||
|
@$(GO) mod tidy -diff >/dev/null 2>&1 || { echo "go.mod не tidy — запусти make tidy"; $(GO) mod tidy -diff; exit 1; }
|
||||||
|
@echo "tidy: ok"
|
||||||
|
|
||||||
|
# Локальный гейт (то же, что CI).
|
||||||
|
ci: build test lint tidy-check
|
||||||
|
|
||||||
|
# Демо: поднять локальный стенд и прогнать сквозной сценарий.
|
||||||
|
demo:
|
||||||
|
docker compose -f examples/demo/docker-compose.yml up -d
|
||||||
|
$(GO) build -o omnichannel-mcp .
|
||||||
|
python3 examples/demo/demo.py
|
||||||
|
|
||||||
|
# E2E: то же плюс реальный config-agent, исполняющий задачи деплоя.
|
||||||
|
e2e:
|
||||||
|
docker compose -f examples/demo/docker-compose.yml up -d
|
||||||
|
$(GO) build -o omnichannel-mcp .
|
||||||
|
python3 examples/demo/e2e.py
|
||||||
|
|
||||||
|
demo-down:
|
||||||
|
docker compose -f examples/demo/docker-compose.yml down -v
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
# omnichannel-mcp
|
||||||
|
|
||||||
|
MCP-сервер для управления платформой **Omnichannel** через API
|
||||||
|
[`config_server`](../API.md): развёртывание релизов и эксплуатация — из любого
|
||||||
|
AI-ассистента, без веб-интерфейса.
|
||||||
|
|
||||||
|
Сервер реализует [Model Context Protocol](https://modelcontextprotocol.io) и
|
||||||
|
работает как обычный stdio-процесс: Claude Desktop, Cursor или любой другой
|
||||||
|
MCP-клиент запускает его локально, а он транслирует запросы ассистента в HTTP API
|
||||||
|
`config_server`. Состояния не хранит.
|
||||||
|
|
||||||
|
## Возможности
|
||||||
|
|
||||||
|
- **Развёртывание** — схема и манифест стенда, выгрузка релиза (пакеты и
|
||||||
|
образы), запуск сервисов на одном или нескольких серверах, обновление фронта.
|
||||||
|
- **Эксплуатация** — сводка по стенду, статусы сервисов и хостов, аудит задач
|
||||||
|
агентов, журналы миграций, диагностика сопоставления IP.
|
||||||
|
- **Конфигурация** — чтение и изменение compose/env, версии и безопасный откат,
|
||||||
|
повторная выдача конфига агенту.
|
||||||
|
- **Безопасность** — режим «только чтение», отдельные read-only креды,
|
||||||
|
подтверждение разрушительных операций, секреты только из окружения.
|
||||||
|
|
||||||
|
## Требования
|
||||||
|
|
||||||
|
- Запущенный **`config_server` 1.1.0+**, доступный по сети (`http://host:5005`).
|
||||||
|
- Учётная запись `config_server`.
|
||||||
|
- **Go 1.27+** для сборки.
|
||||||
|
- MCP-клиент, поддерживающий stdio-серверы.
|
||||||
|
|
||||||
|
## Быстрый старт
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export GOPRIVATE=git.totmin.ru
|
||||||
|
go build -o omnichannel-mcp .
|
||||||
|
./omnichannel-mcp --health # -> ok
|
||||||
|
|
||||||
|
cp examples/config.single-server.json config.json # укажите base_url
|
||||||
|
export OMNI_USER=admin OMNI_PASSWORD='...'
|
||||||
|
./omnichannel-mcp --check-config -config config.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Подключение к клиенту (общий вид):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"omnichannel-mcp": {
|
||||||
|
"command": "/opt/omnichannel-mcp/omnichannel-mcp",
|
||||||
|
"args": ["-config", "/opt/omnichannel-mcp/config.json"],
|
||||||
|
"env": { "OMNI_USER": "admin", "OMNI_PASSWORD": "СЕКРЕТ" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Проверка: попросите ассистента «покажи, что сейчас на стенде» — он вызовет
|
||||||
|
`server_info` и `overview`.
|
||||||
|
|
||||||
|
## Пример: деплой за несколько шагов
|
||||||
|
|
||||||
|
**Один сервис** (уже зарегистрирован в `config_server`):
|
||||||
|
|
||||||
|
> «Обнови compose сервиса `demo_web` на образ `nginx:1.27`, подними и дождись
|
||||||
|
> результата.»
|
||||||
|
|
||||||
|
Ассистент: `set_compose` → `deploy` → `get_task` → `overview`.
|
||||||
|
|
||||||
|
**Релиз на несколько серверов:**
|
||||||
|
|
||||||
|
> «Сохрани schema.json и manifest.json, разложи хосты, скачай релиз с образами,
|
||||||
|
> дождись завершения и запусти сервисы.»
|
||||||
|
|
||||||
|
Ассистент: `save_deployment_files` → `ip_match` → `seed_hosts` →
|
||||||
|
`download_release` → `release_job` → `start_services` → `deployment_tasks` →
|
||||||
|
`overview`.
|
||||||
|
|
||||||
|
Подробные playbooks (1 сервер, N серверов, фронт, откат) с форматами
|
||||||
|
`schema.json`/`manifest.json` — [docs/deployment.md](docs/deployment.md).
|
||||||
|
Готовые формулировки промптов — [examples/prompts.md](examples/prompts.md).
|
||||||
|
|
||||||
|
## Демо без прода
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f examples/demo/docker-compose.yml up -d # локальный config_server v1.1.0
|
||||||
|
go build -o omnichannel-mcp .
|
||||||
|
python3 examples/demo/demo.py # сквозной сценарий через MCP
|
||||||
|
python3 examples/demo/e2e.py # + реальный config-agent: deploy/restart/down
|
||||||
|
docker compose -f examples/demo/docker-compose.yml down -v
|
||||||
|
```
|
||||||
|
|
||||||
|
Подробнее — [examples/demo/README.md](examples/demo/README.md).
|
||||||
|
|
||||||
|
## Документация
|
||||||
|
|
||||||
|
- [docs/getting-started.md](docs/getting-started.md) — установка и первое подключение
|
||||||
|
- [docs/configuration.md](docs/configuration.md) — полный справочник конфигурации
|
||||||
|
- [docs/tools.md](docs/tools.md) — все 36 инструментов
|
||||||
|
- [docs/deployment.md](docs/deployment.md) — развёртывание: 1 сервер, N серверов, фронт, откат
|
||||||
|
- [docs/operations.md](docs/operations.md) — эксплуатация и диагностика
|
||||||
|
- [docs/security.md](docs/security.md) — модель безопасности
|
||||||
|
- [docs/architecture.md](docs/architecture.md) — как устроено внутри
|
||||||
|
- [docs/api-compatibility.md](docs/api-compatibility.md) — версии API
|
||||||
|
- [docs/mcp-clients.md](docs/mcp-clients.md) — подключение к клиентам
|
||||||
|
|
||||||
|
## Безопасность (кратко)
|
||||||
|
|
||||||
|
- `read_only: true` по умолчанию; мутации требуют явного `read_only=false`.
|
||||||
|
- Разрушительные операции требуют `confirm="true"`.
|
||||||
|
- Read-инструменты могут ходить под отдельными read-only кредами.
|
||||||
|
- Секреты — только из переменных окружения или `config.local.json` (не в git).
|
||||||
|
- Подробно — [docs/security.md](docs/security.md).
|
||||||
|
|
||||||
|
## Разработка
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export GOPRIVATE=git.totmin.ru
|
||||||
|
make build # собрать бинарник
|
||||||
|
make ci # build + test(race) + lint + tidy-check
|
||||||
|
make demo # поднять локальный стенд и прогнать сквозной сценарий
|
||||||
|
```
|
||||||
|
|
||||||
|
Подробнее — [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||||
|
|
||||||
|
## Лицензия
|
||||||
|
|
||||||
|
Apache-2.0 — см. [LICENSE](LICENSE). Внутренний проект компании.
|
||||||
+14
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"servers": [
|
||||||
|
{
|
||||||
|
"alias": "prod",
|
||||||
|
"base_url": "http://config-server.example:5005",
|
||||||
|
"username": "${OMNI_USER}",
|
||||||
|
"password": "${OMNI_PASSWORD}",
|
||||||
|
"front_roots": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"default": "prod",
|
||||||
|
"read_only": true,
|
||||||
|
"allow_hosts": []
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# Совместимость с версиями API
|
||||||
|
|
||||||
|
`omnichannel-mcp` рассчитан на API **`config_server` 1.1.0** (он же `latest`).
|
||||||
|
Эта версия добавила JSON-эндпоинты, на которые опирается сервер.
|
||||||
|
|
||||||
|
## Что появилось в 1.1.0
|
||||||
|
|
||||||
|
| Эндпоинт | Назначение |
|
||||||
|
|---|---|
|
||||||
|
| `POST /api/login`, `POST /api/logout` | JSON-авторизация для сторонних клиентов |
|
||||||
|
| `GET /api/whoami` | Проверка сессии |
|
||||||
|
| `GET /api/application/<id>` | Детализация сервиса в JSON |
|
||||||
|
| `POST /api/compose/<id>` | Установка compose через JSON |
|
||||||
|
| `POST /api/env/<id>/<file>` | Установка env через JSON |
|
||||||
|
| `GET /api/tasks`, `GET /api/task/<id>` | Список/статус задач |
|
||||||
|
|
||||||
|
В более старых версиях (≤ 1.0.20) этих эндпоинтов нет: логин был только
|
||||||
|
HTML-формой, конфиг правился HTML-страницами, а список задач — только по хосту.
|
||||||
|
`update_front` появился раньше (1.0.19+).
|
||||||
|
|
||||||
|
## Capability-probe
|
||||||
|
|
||||||
|
Инструмент `server_info` определяет версию автоматически (по доступности
|
||||||
|
`GET /api/whoami`) и возвращает:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"server": "prod",
|
||||||
|
"base_url": "http://10.20.30.40:5005",
|
||||||
|
"read_only": false,
|
||||||
|
"capabilities": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"features": { "api_login": true, "set_compose": true, "tasks_filter": true, … }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
На старом стенде `version` будет `legacy (<1.1.0)`, а `features` — пустым. В этом
|
||||||
|
случае инструменты, требующие новых эндпоинтов, вернут понятное сообщение вместо
|
||||||
|
«тихой» поломки. Наблюдение и часть операций на старых версиях недоступны —
|
||||||
|
обновите `config_server`.
|
||||||
|
|
||||||
|
## Рекомендация
|
||||||
|
|
||||||
|
Перед автоматизацией вызовите `server_info` и убедитесь, что `version` = `1.1.0`.
|
||||||
|
Если планируется смешанный парк — запускайте отдельный инстанс модуля на каждый
|
||||||
|
контур со своим `server`-конфигом.
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
# Архитектура
|
||||||
|
|
||||||
|
## Место в системе
|
||||||
|
|
||||||
|
```
|
||||||
|
┌────────────────┐ MCP (stdio, JSON-RPC) ┌──────────────────┐
|
||||||
|
│ MCP-клиент │ ◄──────────────────────► │ omnichannel-mcp │
|
||||||
|
│ (AI-ассистент)│ │ (этот сервер) │
|
||||||
|
└────────────────┘ └────────┬─────────┘
|
||||||
|
│ HTTP (session)
|
||||||
|
▼
|
||||||
|
┌──────────────────┐
|
||||||
|
│ config_server │
|
||||||
|
│ (Flask API/UI) │
|
||||||
|
└────────┬─────────┘
|
||||||
|
│ задачи агентам
|
||||||
|
▼
|
||||||
|
┌───────────────────────────┐
|
||||||
|
│ агенты на хостах │
|
||||||
|
│ docker-compose / swarm │
|
||||||
|
└───────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
`omnichannel-mcp` — тонкий клиент. Он не выполняет команды сам: создаёт задачи
|
||||||
|
через API `config_server`, а те забирают агенты нод. Сервер без состояния — при
|
||||||
|
перезапуске ничего не теряется.
|
||||||
|
|
||||||
|
## Модель задач
|
||||||
|
|
||||||
|
Действия жизненного цикла (`deploy`, `restart`, `down`, `migrate`,
|
||||||
|
`update_front`, релизные операции) асинхронны:
|
||||||
|
|
||||||
|
1. Инструмент вызывает API → создаётся задача → возвращается `task_id`.
|
||||||
|
2. Агент ноды забирает задачу, выполняет `docker-compose`, публикует результат.
|
||||||
|
3. Клиент опрашивает `get_task` до `completed`/`failed`.
|
||||||
|
|
||||||
|
Поэтому у инструментов есть параметр `wait`: по умолчанию он выключен (loop
|
||||||
|
ассистента последовательный — не стоит его блокировать), а при `wait=true`
|
||||||
|
ожидание ограничено `task_poll_max_sec` и не считается ошибкой, если время вышло:
|
||||||
|
вернётся текущее состояние задачи.
|
||||||
|
|
||||||
|
## Слои модуля
|
||||||
|
|
||||||
|
```
|
||||||
|
main.go каркас lifecycle: --health, -config, stdio
|
||||||
|
internal/configserver/ домен: без зависимостей от MCP
|
||||||
|
config.go разбор/валидация конфига, ${VAR}, merge local
|
||||||
|
session.go HTTP-клиент: cookie-сессия, релогin, ретраи GET
|
||||||
|
manager.go потокобезопасный диспетчер: конфиг, сессии, семафоры
|
||||||
|
api_*.go типизированные вызовы эндпоинтов config_server
|
||||||
|
internal/tools/ MCP-слой: разбор аргументов → вызов домена → текст
|
||||||
|
registry.go toolkit.go patterns.go util.go
|
||||||
|
observe.go configure.go operate.go release.go maintain.go overview.go
|
||||||
|
```
|
||||||
|
|
||||||
|
Правило разделения: домен тестируется без MCP; MCP-слой не содержит бизнес-логики.
|
||||||
|
|
||||||
|
## Надёжность
|
||||||
|
|
||||||
|
- **Сессия:** Flask-cookie хранится в cookie-jar; login ленивый, при `401`
|
||||||
|
выполняется один повторный вход и один повтор запроса.
|
||||||
|
- **Повторы:** только идемпотентные GET (сеть/5xx). Мутации не повторяются.
|
||||||
|
- **Ограничения:** context-deadline на каждый вызов, лимит на размер ответа,
|
||||||
|
обрезка вывода, семафор мутаций на сервер.
|
||||||
|
- **Ошибки:** доменные (4xx/5xx с текстом, ошибки валидации) возвращаются как
|
||||||
|
recoverable — ассистент видит причину и может исправить; инфраструктурные
|
||||||
|
(сеть) — как ошибка протокола.
|
||||||
|
- **Логи:** только в stderr (stdout занят JSON-RPC), с маскированием секретов.
|
||||||
|
|
||||||
|
## Логи и наблюдаемость
|
||||||
|
|
||||||
|
Логи модуля пишутся в stderr; уровень задаётся `--log-level` или `OMNI_LOG_LEVEL`
|
||||||
|
(`debug|info|warn|error`). HTTP-сервер `config_server` — источник данных о
|
||||||
|
задачах и статусах.
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
# Конфигурация
|
||||||
|
|
||||||
|
Конфиг — JSON-файл (по умолчанию `config.json`), который задаёт подключения к
|
||||||
|
`config_server` и политику безопасности. Рядом можно положить
|
||||||
|
`config.local.json` с секретами — он накладывается поверх и не коммитится.
|
||||||
|
|
||||||
|
## Как задать путь к конфигу
|
||||||
|
|
||||||
|
Приоритет (первый найденный):
|
||||||
|
|
||||||
|
1. аргумент инструмента `config_path` (для обёрток; обычно не нужен);
|
||||||
|
2. флаг запуска `-config /path/config.json`;
|
||||||
|
3. каталог из переменной окружения `OMNI_CONFIG_DIR` (в нём берётся
|
||||||
|
`omnichannel-mcp.json`).
|
||||||
|
|
||||||
|
Обычный режим — флаг `-config`.
|
||||||
|
|
||||||
|
## Структура
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"servers": [ { … }, { … } ],
|
||||||
|
"default": "prod",
|
||||||
|
"read_only": true,
|
||||||
|
"allow_hosts": ["10.20.30.40"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Поле | Тип | По умолчанию | Описание |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `servers` | массив | — | Подключения к стендам. Обязательно, если не задан shorthand. |
|
||||||
|
| `default` | строка | первый сервер | Алиас сервера, если в вызове не указан `server`. |
|
||||||
|
| `read_only` | bool | `true` | Запрещает изменяющие инструменты (безопасный режим). |
|
||||||
|
| `allow_hosts` | массив | пусто | Если задан — `base_url` любого сервера обязан быть на этих хостах. |
|
||||||
|
|
||||||
|
### Поля сервера
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"alias": "prod",
|
||||||
|
"base_url": "http://10.20.30.40:5005",
|
||||||
|
"username": "${OMNI_USER}",
|
||||||
|
"password": "${OMNI_PASSWORD}",
|
||||||
|
"readonly_username": "${OMNI_RO_USER}",
|
||||||
|
"readonly_password": "${OMNI_RO_PASSWORD}",
|
||||||
|
"gitlab_token": "${OMNI_GITLAB_TOKEN}",
|
||||||
|
"insecure_skip_verify": false,
|
||||||
|
"front_roots": ["/srv/omni-front-builds"],
|
||||||
|
"timeout_sec": 30,
|
||||||
|
"upload_timeout_sec": 300,
|
||||||
|
"task_poll_max_sec": 120,
|
||||||
|
"task_poll_interval_sec": 2,
|
||||||
|
"max_output_bytes": 100000,
|
||||||
|
"max_upload_bytes": 536870912,
|
||||||
|
"max_concurrent_mutations": 1
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Поле | По умолчанию | Назначение |
|
||||||
|
|---|---|---|
|
||||||
|
| `alias` | — | Имя стенда для выбора сервером (`server:"prod"`). |
|
||||||
|
| `base_url` | — | Адрес API `config_server` (http/https). Обязательно. |
|
||||||
|
| `username` / `password` | — | Учётная запись с правом записи. |
|
||||||
|
| `readonly_username` / `readonly_password` | — | Необязательная учётная запись только для чтения (read-инструменты используют её). |
|
||||||
|
| `gitlab_token` | — | Токен для `download_release` (если не задан — используется значение сервера/окружения). |
|
||||||
|
| `insecure_skip_verify` | `false` | Отключить проверку TLS-сертификата (самоподписанные стенды). |
|
||||||
|
| `front_roots` | `[]` | Разрешённые каталоги локальных сборок фронта (jail). Пусто — `update_front` запрещён. |
|
||||||
|
| `timeout_sec` | `30` | Таймаут обычных запросов. |
|
||||||
|
| `upload_timeout_sec` | `300` | Таймаут загрузки фронта и синхронной выгрузки релиза. |
|
||||||
|
| `task_poll_max_sec` | `120` | Максимум ожидания задачи при `wait=true`. |
|
||||||
|
| `task_poll_interval_sec` | `2` | Интервал опроса задачи. |
|
||||||
|
| `max_output_bytes` | `100000` | Лимит длины вывода задачи (обрезается, хвост сохраняется). |
|
||||||
|
| `max_upload_bytes` | `536870912` | Лимит суммарного размера сборки фронта (512 МиБ). |
|
||||||
|
| `max_concurrent_mutations` | `1` | Максимум одновременных изменяющих операций на сервер. |
|
||||||
|
|
||||||
|
## Секреты
|
||||||
|
|
||||||
|
Два способа, оба безопасны (не попадают в репозиторий):
|
||||||
|
|
||||||
|
**1. Переменные окружения** — подстановка `${VAR}` в `config.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "password": "${OMNI_PASSWORD}" }
|
||||||
|
```
|
||||||
|
|
||||||
|
Переменная должна быть задана в окружении процесса MCP-сервера. Если её нет —
|
||||||
|
сервер откажется стартовать и явно укажет имя переменной (fail-closed).
|
||||||
|
|
||||||
|
**2. `config.local.json`** — файл рядом с `config.json` (в `.gitignore`),
|
||||||
|
накладывается поверх: объекты сливаются, серверы — по `alias`.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"servers": [
|
||||||
|
{ "alias": "prod", "password": "реальный-пароль" }
|
||||||
|
],
|
||||||
|
"read_only": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Shorthand для одного сервера
|
||||||
|
|
||||||
|
Вместо массива `servers` можно задать корневые поля — это один сервер с
|
||||||
|
алиасом `default`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"base_url": "http://10.20.30.40:5005",
|
||||||
|
"username": "${OMNI_USER}",
|
||||||
|
"password": "${OMNI_PASSWORD}"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Несколько стендов
|
||||||
|
|
||||||
|
Каждому стенду — свой `alias`; инструменты принимают аргумент `server`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"servers": [
|
||||||
|
{ "alias": "prod", "base_url": "http://10.20.30.40:5005", "username": "${P_USER}", "password": "${P_PASS}" },
|
||||||
|
{ "alias": "stage", "base_url": "http://10.20.31.40:5005", "username": "${S_USER}", "password": "${S_PASS}" }
|
||||||
|
],
|
||||||
|
"default": "prod",
|
||||||
|
"allow_hosts": ["10.20.30.40", "10.20.31.40"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Пример целиком — [../examples/config.multi-server.json](../examples/config.multi-server.json).
|
||||||
|
|
||||||
|
## Режим «только чтение» (наблюдатель)
|
||||||
|
|
||||||
|
Для мониторинга удобно поднять отдельный инстанс без права изменений:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"servers": [{ "alias": "prod", "base_url": "http://10.20.30.40:5005",
|
||||||
|
"readonly_username": "${OMNI_RO_USER}",
|
||||||
|
"readonly_password": "${OMNI_RO_PASSWORD}" }],
|
||||||
|
"read_only": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Пример — [../examples/config.readonly-observer.json](../examples/config.readonly-observer.json).
|
||||||
|
|
||||||
|
## Проверка конфига
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./omnichannel-mcp --check-config -config config.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Печатает итоговый (слитый) конфиг с замаскированными секретами, список серверов
|
||||||
|
и `default`. Код возврата ≠ 0 при ошибке — удобно для CI.
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
# Развёртывание
|
||||||
|
|
||||||
|
Два пути: **быстрый** (для уже зарегистрированного сервиса — изменить compose/env
|
||||||
|
и поднять) и **полный релиз** (схема + манифест → выгрузка релиза → запуск на
|
||||||
|
одном или нескольких серверах).
|
||||||
|
|
||||||
|
> Перед изменяющими операциями убедитесь, что `read_only=false` в конфиге, а
|
||||||
|
> ассистент запрашивает подтверждение (`confirm`) на разрушительные шаги.
|
||||||
|
|
||||||
|
## Быстрый путь: один уже известный сервис
|
||||||
|
|
||||||
|
```text
|
||||||
|
Промпт: «Обнови compose сервиса demo_web на образ nginx:1.27, подними его и дождись результата.»
|
||||||
|
```
|
||||||
|
|
||||||
|
Вызовы:
|
||||||
|
|
||||||
|
1. `list_applications` — найти `demo_web` и его `app_id` (или `get_application`, если id известен).
|
||||||
|
2. `set_compose {app_id, content}` — записать новый compose (создаётся версия, конфиг уходит агенту).
|
||||||
|
При необходимости — `set_env {app_id, filename, content}`.
|
||||||
|
3. `deploy {app_id, wait:true}` — `docker-compose up -d` и ожидание задачи.
|
||||||
|
4. `overview` — убедиться, что сервис поднялся.
|
||||||
|
|
||||||
|
Для перезапуска — `restart`; для остановки — `down {confirm:true}`.
|
||||||
|
|
||||||
|
## Полный релиз: один сервер
|
||||||
|
|
||||||
|
Минимальная схема — один хост:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"vm1": { "ip": "10.20.30.11", "services": ["user_system_front"] }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```text
|
||||||
|
Промпт: «Сохрани schema.json и manifest.json, разложи хост, скачай релиз с образами,
|
||||||
|
дождись завершения и запусти сервисы.»
|
||||||
|
```
|
||||||
|
|
||||||
|
1. `save_deployment_files {schema, manifest}`
|
||||||
|
2. `ip_match` — проверить, что IP хоста сопоставлен агенту.
|
||||||
|
3. `seed_hosts` — создать запись хоста до выхода агента.
|
||||||
|
4. `download_release {load_images:true, confirm:true}` → `{"status":"started"}`
|
||||||
|
5. `release_job` (опрос) — `status: running` → `completed`
|
||||||
|
6. `start_services` → `start_task_ids`
|
||||||
|
7. `deployment_tasks` / `get_task {task_id, wait:true}` — результат запуска
|
||||||
|
8. `overview` — сводка
|
||||||
|
|
||||||
|
## Полный релиз: несколько серверов
|
||||||
|
|
||||||
|
Схема описывает все VM и их сервисы:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"vm-node-1": { "ip": "10.20.30.11", "services": ["chats", "user_system"] },
|
||||||
|
"vm-node-2": { "ip": "10.20.30.12", "services": ["report_system"] }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Манифест сопоставляет сервис и пакет релиза (URL архива в GitLab):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"services": {
|
||||||
|
"chats": { "package": "https://gitlab.example/api/v4/.../chats.tar.gz" },
|
||||||
|
"user_system": { "package": "https://gitlab.example/api/v4/.../user_system.tar.gz" },
|
||||||
|
"report_system": { "package": "https://gitlab.example/api/v4/.../report_system.tar.gz" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Порядок:
|
||||||
|
|
||||||
|
1. `save_deployment_files {schema, manifest}` (при необходимости — `prebuild_vars`).
|
||||||
|
2. `ip_match` — увидеть `matched_pairs` и `unmatched_schema_ips`.
|
||||||
|
Если IP в схеме не совпадают с IP агентов — задать соответствие через
|
||||||
|
`save_deployment_files {ip_overrides}`:
|
||||||
|
```json
|
||||||
|
{ "10.20.30.11": "10.20.30.101", "10.20.30.12": "10.20.30.102" }
|
||||||
|
```
|
||||||
|
затем снова `ip_match`.
|
||||||
|
3. `fill_vars` (опционально) — заполнить `prebuild_vars` из схемы.
|
||||||
|
4. `seed_hosts` — создать записи хостов.
|
||||||
|
5. `download_release {load_images:true, confirm:true}` — запускает фоновую
|
||||||
|
выгрузку пакетов и образов; прогресс — `release_job`
|
||||||
|
(`phase`, `images_pulled`/`images_total`).
|
||||||
|
Без образов: `download_release {load_images:false}` вернёт
|
||||||
|
`downloaded_services` и `sync_task_ids`.
|
||||||
|
6. `start_services` → `start_task_ids` (по одной задаче на хост).
|
||||||
|
7. `deployment_tasks` и `get_task {wait:true}` — дождаться результата по каждому хосту.
|
||||||
|
8. `overview` — итоговая сводка; `list_applications` — статусы сервисов.
|
||||||
|
|
||||||
|
> Токен GitLab: задайте `gitlab_token` в конфиге сервера (или переменной
|
||||||
|
> окружения) — тогда не придётся передавать секрет в аргументе.
|
||||||
|
|
||||||
|
## Обновление фронта
|
||||||
|
|
||||||
|
Фронт-сервисы (`user_system_front`, `chat_widget`, `scenario_front` и др.)
|
||||||
|
принимают собранную папку.
|
||||||
|
|
||||||
|
1. В конфиге сервера задайте `front_roots` — каталог, куда вы кладёте сборки:
|
||||||
|
```json
|
||||||
|
"front_roots": ["/srv/omni-front-builds"]
|
||||||
|
```
|
||||||
|
2. ```text
|
||||||
|
Промпт: «Обнови фронт user_system_front из /srv/omni-front-builds/us.»
|
||||||
|
```
|
||||||
|
`update_front {app_id, build_dir:"/srv/omni-front-builds/us", confirm:true}` →
|
||||||
|
задача агенту → `get_task {wait:true}`.
|
||||||
|
|
||||||
|
Каталог обязан лежать внутри `front_roots` (защита от загрузки произвольных
|
||||||
|
путей); содержимое `build_folder` на хосте заменяется целиком.
|
||||||
|
|
||||||
|
## Откат конфигурации
|
||||||
|
|
||||||
|
Каждое изменение compose/env создаёт версию, поэтому откат безопасен.
|
||||||
|
|
||||||
|
- Compose: `get_application {app_id}` → выбрать `version_id` → `restore_compose {version_id, confirm:true}`.
|
||||||
|
- Env: `env_versions {app_id, filename}` → `restore_env_version {version_id, confirm:true}`.
|
||||||
|
|
||||||
|
Восстановление создаёт **новую** версию (история сохраняется) и отправляет
|
||||||
|
конфиг агенту.
|
||||||
|
|
||||||
|
## Миграции
|
||||||
|
|
||||||
|
```text
|
||||||
|
Промпт: «Запусти миграции сервиса 5 и проверь результат.»
|
||||||
|
```
|
||||||
|
|
||||||
|
`migrate {app_id:5, wait:true}`; журнал последних миграций — в
|
||||||
|
`get_application` (`migration_logs`).
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
# Быстрый старт
|
||||||
|
|
||||||
|
`omnichannel-mcp` — это **MCP-сервер**: программа, которую AI-ассистент
|
||||||
|
(Claude Desktop, Cursor, Continue и любой другой MCP-клиент) запускает как
|
||||||
|
локальный процесс и через которую получает доступ к платформе Omnichannel.
|
||||||
|
Сервер не хранит состояние и не имеет UI: он лишь транслирует запросы агента в
|
||||||
|
HTTP API `config_server`.
|
||||||
|
|
||||||
|
## Что понадобится
|
||||||
|
|
||||||
|
1. **Запущенный `config_server`** (версия 1.1.0 или новее), доступный по сети.
|
||||||
|
По умолчанию — `http://<host>:5005`. Проверить: `curl http://<host>:5005/health` → `OK`.
|
||||||
|
2. **Учётная запись** `config_server` (логин/пароль администратора).
|
||||||
|
3. **MCP-клиент**, поддерживающий stdio-серверы (Claude Desktop, Cursor и др.).
|
||||||
|
4. Для сборки — **Go 1.27+** и доступ к общему тулкиту (Go-модуль
|
||||||
|
`forge-toolkit` на `git.totmin.ru`; задайте `GOPRIVATE=git.totmin.ru`).
|
||||||
|
|
||||||
|
## 1. Сборка
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd omnichannel-mcp
|
||||||
|
export GOPRIVATE=git.totmin.ru
|
||||||
|
go build -o omnichannel-mcp .
|
||||||
|
./omnichannel-mcp --health # -> ok
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. Конфигурация
|
||||||
|
|
||||||
|
Скопируйте пример и задайте адрес и креды:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp examples/config.single-server.json config.json
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"servers": [
|
||||||
|
{
|
||||||
|
"alias": "prod",
|
||||||
|
"base_url": "http://10.20.30.40:5005",
|
||||||
|
"username": "${OMNI_USER}",
|
||||||
|
"password": "${OMNI_PASSWORD}"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"default": "prod",
|
||||||
|
"read_only": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Секреты удобно держать в переменных окружения или в `config.local.json`
|
||||||
|
(подробно — [configuration.md](configuration.md)).
|
||||||
|
|
||||||
|
Проверьте конфиг (секреты маскируются) — это самая частая причина проблем:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export OMNI_USER=admin OMNI_PASSWORD='...'
|
||||||
|
./omnichannel-mcp --check-config -config config.json
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. Подключение к MCP-клиенту
|
||||||
|
|
||||||
|
Общий вид конфигурации (для любого клиента, поддерживающего `mcpServers`):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"omnichannel-mcp": {
|
||||||
|
"command": "/opt/omnichannel-mcp/omnichannel-mcp",
|
||||||
|
"args": ["-config", "/opt/omnichannel-mcp/config.json"],
|
||||||
|
"env": {
|
||||||
|
"OMNI_USER": "admin",
|
||||||
|
"OMNI_PASSWORD": "СЕКРЕТ"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Готовые сниппеты для конкретных клиентов — [mcp-clients.md](mcp-clients.md).
|
||||||
|
|
||||||
|
## 4. Проверка
|
||||||
|
|
||||||
|
Попросите ассистента:
|
||||||
|
|
||||||
|
> «Проверь подключение к Omnichannel и покажи, что сейчас на стенде.»
|
||||||
|
|
||||||
|
Ассистент вызовет `server_info` (версия API и режим), затем `overview` (сводка).
|
||||||
|
Если видите корректную версию `1.1.0` и список сервисов — всё работает.
|
||||||
|
|
||||||
|
## 5. Первый деплой
|
||||||
|
|
||||||
|
Самый быстрый сценарий для уже зарегистрированного сервиса:
|
||||||
|
|
||||||
|
> «Обнови compose сервиса `demo_web` на образ `nginx:1.27`, подними его и
|
||||||
|
> дождись результата.»
|
||||||
|
|
||||||
|
Последовательность вызовов и подробные сценарии (1 сервер, N серверов, фронт,
|
||||||
|
откат) — [deployment.md](deployment.md). Готовые промпты —
|
||||||
|
[../examples/prompts.md](../examples/prompts.md).
|
||||||
|
|
||||||
|
## Демо-стенд (без прода)
|
||||||
|
|
||||||
|
Поднять локальный `config_server` и прогнать сквозной пример:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f examples/demo/docker-compose.yml up -d
|
||||||
|
go build -o omnichannel-mcp .
|
||||||
|
python3 examples/demo/demo.py
|
||||||
|
```
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
# Подключение к MCP-клиентам
|
||||||
|
|
||||||
|
`omnichannel-mcp` — обычный stdio-сервер. Достаточно указать команду запуска,
|
||||||
|
аргумент `-config` и (при необходимости) переменные окружения с секретами.
|
||||||
|
|
||||||
|
## Claude Desktop
|
||||||
|
|
||||||
|
Файл конфигурации:
|
||||||
|
|
||||||
|
- macOS: `~/Library/Application Support/Claude/claude_desktop_config.json`
|
||||||
|
- Windows: `%APPDATA%\Claude\claude_desktop_config.json`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"omnichannel-mcp": {
|
||||||
|
"command": "/opt/omnichannel-mcp/omnichannel-mcp",
|
||||||
|
"args": ["-config", "/opt/omnichannel-mcp/config.json"],
|
||||||
|
"env": {
|
||||||
|
"OMNI_USER": "admin",
|
||||||
|
"OMNI_PASSWORD": "СЕКРЕТ"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cursor
|
||||||
|
|
||||||
|
Файл `~/.cursor/mcp.json` (или `.cursor/mcp.json` в проекте):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"omnichannel-mcp": {
|
||||||
|
"command": "/opt/omnichannel-mcp/omnichannel-mcp",
|
||||||
|
"args": ["-config", "/opt/omnichannel-mcp/config.json"],
|
||||||
|
"env": { "OMNI_USER": "admin", "OMNI_PASSWORD": "СЕКРЕТ" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Любой другой stdio-клиент
|
||||||
|
|
||||||
|
Форма одна: `command` + `args` + `env`. Минимально достаточно:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "command": "/opt/omnichannel-mcp/omnichannel-mcp",
|
||||||
|
"args": ["-config", "/opt/omnichannel-mcp/config.json"] }
|
||||||
|
```
|
||||||
|
|
||||||
|
Если секреты лежат в `config.local.json`, переменные окружения не нужны.
|
||||||
|
|
||||||
|
## Проверка подключения
|
||||||
|
|
||||||
|
- Из терминала: `./omnichannel-mcp --check-config -config config.json` и
|
||||||
|
`./omnichannel-mcp --health`.
|
||||||
|
- В клиенте: попросите ассистента вызвать `server_info` — он покажет версию API
|
||||||
|
и режим. Затем `overview` — сводку по стенду.
|
||||||
|
|
||||||
|
## Несколько стендов
|
||||||
|
|
||||||
|
Один инстанс сервера может обслуживать несколько стендов (`servers` в конфиге).
|
||||||
|
Тогда в вызовах используется аргумент `server`. Если нужна полная изоляция
|
||||||
|
(разные пользователи/права) — запустите отдельные инстансы (разные `-config` и,
|
||||||
|
при желании, `read_only`), каждый со своим именем в `mcpServers`.
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
# Эксплуатация
|
||||||
|
|
||||||
|
`omnichannel-mcp` работает в **плоскости управления**: он видит статусы,
|
||||||
|
конфигурацию, задачи и историю, но не заглядывает внутрь контейнеров. Это
|
||||||
|
разделение осознанное — так сервер остаётся безопасным и предсказуемым.
|
||||||
|
|
||||||
|
## Что можно, а что нет
|
||||||
|
|
||||||
|
| Задача | Инструменты этого сервера | Вне него |
|
||||||
|
|---|---|---|
|
||||||
|
| Статусы сервисов и хостов | `overview`, `list_applications`, `service_map` | — |
|
||||||
|
| Аудит и разбор задач | `list_tasks`, `get_task`, `deployment_tasks` | — |
|
||||||
|
| Конфигурация compose/env, версии, откат | `get_application`, `get_config`, `env_files`, `compose_version`, `env_versions`, `restore_compose`, `restore_env_version` | — |
|
||||||
|
| Жизненный цикл сервиса | `deploy`, `restart`, `down`, `migrate` | — |
|
||||||
|
| Развёртывание релиза | `save_deployment_files`, `seed_hosts`, `fill_vars`, `download_release`, `release_job`, `start_services` | — |
|
||||||
|
| **Логи контейнеров** | — | отдельный MCP-сервер SSH/`docker logs` |
|
||||||
|
| **Ресурсы (CPU/RAM/диск)** | — | мониторинг (SSH, Zabbix, Prometheus) |
|
||||||
|
| **Данные в БД** | — | отдельный MCP-сервер для БД |
|
||||||
|
| **HTTP-проверки сервисов** | — | отдельный MCP-сервер для HTTP/веб |
|
||||||
|
|
||||||
|
Иными словами: этим сервером удобно отвечать на «что и с какой конфигурацией
|
||||||
|
развёрнуто и что сломалось при деплое», а диагностику рантайма подключайте
|
||||||
|
дополнительными MCP-серверами.
|
||||||
|
|
||||||
|
## Типовые сценарии
|
||||||
|
|
||||||
|
### «Что сейчас на стенде?»
|
||||||
|
|
||||||
|
> «Покажи сводку по стенду prod.»
|
||||||
|
|
||||||
|
`overview` вернёт health, статистику, сервисы не в статусе OK и последние
|
||||||
|
упавшие задачи — это самый быстрый «снимок».
|
||||||
|
|
||||||
|
### «Почему упал деплой?»
|
||||||
|
|
||||||
|
1. `list_tasks {status:"failed", limit:20}` — найти задачу.
|
||||||
|
2. `get_task {task_id}` — прочитать `output` (stdout/stderr). По умолчанию
|
||||||
|
оставляется **хвост** вывода, где обычно и причина; при необходимости
|
||||||
|
увеличить `output_bytes`.
|
||||||
|
3. При необходимости сравнить конфиг: `get_application {app_id}`.
|
||||||
|
|
||||||
|
### «Сервис не поднялся / зависает»
|
||||||
|
|
||||||
|
1. `get_application {app_id}` — статус, текущий compose/env, журнал миграций.
|
||||||
|
2. `list_tasks {host_ip, task_type}` — история операций по хосту.
|
||||||
|
3. Повторить контролируемо: `restart {app_id, wait:true}` и снова `get_task`.
|
||||||
|
4. Если внутри сервиса — переключиться на SSH-инструменты (логи).
|
||||||
|
|
||||||
|
### «Нужно поправить конфигурацию»
|
||||||
|
|
||||||
|
1. `get_config {app_id}` / `get_application {app_id}` — текущее.
|
||||||
|
2. `set_env` / `set_compose` — изменить (создаётся версия).
|
||||||
|
3. `deploy` или `restart` — применить на хосте.
|
||||||
|
4. Проверить `get_task`/`overview`. При неудаче — откат (`restore_*`).
|
||||||
|
|
||||||
|
### «Проверить релиз»
|
||||||
|
|
||||||
|
- `release_job` — статус фоновой выгрузки (фаза, прогресс образов).
|
||||||
|
- `deployment_tasks` — последние sync/start-задачи.
|
||||||
|
- `ip_match` — корректно ли сопоставлены хосты и агенты.
|
||||||
|
|
||||||
|
### «Обслуживание»
|
||||||
|
|
||||||
|
- `stats` — сколько версий и логов накопилось.
|
||||||
|
- `cleanup_old_versions {days:180, confirm:true}` — удалить устаревшие версии
|
||||||
|
(текущие не трогаются).
|
||||||
|
|
||||||
|
## Полезные фильтры
|
||||||
|
|
||||||
|
- `list_tasks` ограничивает выборку (`limit` ≤ 500) и фильтрует по `host_ip`,
|
||||||
|
`status`, `task_type`.
|
||||||
|
- `list_applications {include_presence:true}` включает служебные записи хостов
|
||||||
|
(хост без развёрнутых сервисов).
|
||||||
|
|
||||||
|
## Ограничения и защита
|
||||||
|
|
||||||
|
- Длинные выводы обрезаются (`max_output_bytes`), чтобы не переполнить контекст
|
||||||
|
ассистента.
|
||||||
|
- Ожидание задач (`wait`) ограничено `task_poll_max_sec`; если задача не успела —
|
||||||
|
вернётся её текущее состояние, а не ошибка.
|
||||||
|
- Мутации сериализуются на сервер (`max_concurrent_mutations`) — параллельные
|
||||||
|
`restart` не наложатся друг на друга.
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
# Безопасность
|
||||||
|
|
||||||
|
`omnichannel-mcp` даёт ассистенту право управлять платформой, поэтому
|
||||||
|
безопасность строится в несколько независимых слоёв: конфигурация модуля,
|
||||||
|
подтверждения, и ограничения на стороне `config_server`.
|
||||||
|
|
||||||
|
## Модель угроз
|
||||||
|
|
||||||
|
- **Ошибочный или злонамеренный вызов управляющего инструмента** (остановить
|
||||||
|
прод, залить чужой конфиг).
|
||||||
|
- **Утечка секретов** (пароли, токен GitLab) в логи, в контекст модели, в
|
||||||
|
репозиторий.
|
||||||
|
- **Доступ не туда** (SSRF, подмена хоста стенда).
|
||||||
|
- **Чрезмерные привилегии** (учётная запись только для чтения там, где нужен
|
||||||
|
мониторинг).
|
||||||
|
|
||||||
|
## Слои защиты
|
||||||
|
|
||||||
|
### 1. Учётные данные и секреты
|
||||||
|
|
||||||
|
- Пароли и токены — только из `${VAR}` окружения или `config.local.json`
|
||||||
|
(в `.gitignore`). В репозитории — лишь шаблон.
|
||||||
|
- `--check-config` печатает конфиг с маской секретов — можно безопасно проверять
|
||||||
|
настройку и выкладывать вывод в тикеты.
|
||||||
|
- Секреты не логируются: логи идут в stderr с маскированием, токен GitLab в
|
||||||
|
выводе не отражается.
|
||||||
|
|
||||||
|
### 2. Режим «только чтение»
|
||||||
|
|
||||||
|
- `read_only: true` (значение по умолчанию) запрещает все изменяющие инструменты.
|
||||||
|
- Для наблюдательных агентов поднимайте **отдельный инстанс** с `read_only: true`
|
||||||
|
и отдельной read-only учётной записью:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "servers": [{ "alias": "prod", "base_url": "http://…",
|
||||||
|
"readonly_username": "${OMNI_RO_USER}",
|
||||||
|
"readonly_password": "${OMNI_RO_PASSWORD}" }],
|
||||||
|
"read_only": true }
|
||||||
|
```
|
||||||
|
- Если заданы `readonly_*`-креды, read-инструменты ходят именно под ними —
|
||||||
|
это least-privilege на стороне модуля.
|
||||||
|
|
||||||
|
### 3. Подтверждения (`confirm`)
|
||||||
|
|
||||||
|
Разрушительные операции требуют `confirm="true"`:
|
||||||
|
|
||||||
|
- `down`, `restore_compose`, `restore_env_version`, `update_front`,
|
||||||
|
`download_release`, `cleanup_old_versions`.
|
||||||
|
|
||||||
|
Это второй слой: даже при включённом `read_only=false` ассистент обязан явно
|
||||||
|
подтвердить опасное действие. Дополнительно настройте политику подтверждений
|
||||||
|
вашего MCP-клиента (человеческое «да/нет» на управляющие инструменты).
|
||||||
|
|
||||||
|
### 4. Изоляция сети (`allow_hosts`)
|
||||||
|
|
||||||
|
Если задан `allow_hosts`, `base_url` любого сервера обязан быть на этих хостах —
|
||||||
|
защита от опечаток и подмены адреса.
|
||||||
|
|
||||||
|
### 5. Файловый jail (`front_roots`)
|
||||||
|
|
||||||
|
`update_front` принимает только каталоги внутри `front_roots`; при проверке
|
||||||
|
разрешаются симлинки и отсекаются побеги (`..`, ссылки наружу). Пусто —
|
||||||
|
загрузка фронта запрещена полностью.
|
||||||
|
|
||||||
|
### 6. Ограничение нагрузки
|
||||||
|
|
||||||
|
- Таймауты (`timeout_sec`, `upload_timeout_sec`), лимиты вывода
|
||||||
|
(`max_output_bytes`) и размера загрузки (`max_upload_bytes`).
|
||||||
|
- Семафор мутаций (`max_concurrent_mutations`) предотвращает наложение операций.
|
||||||
|
- GET-запросы повторяются при сетевом сбое; **мутации не повторяются** — действие
|
||||||
|
не выполнится дважды.
|
||||||
|
|
||||||
|
## Что модуль НЕ делает
|
||||||
|
|
||||||
|
- Не работает с агентскими эндпоинтами `config_server`
|
||||||
|
(`/api/register`, `/api/tasks/<host>`) — только с пользовательским API.
|
||||||
|
- Не хранит состояние и не пишет секреты на диск.
|
||||||
|
- Не выполняет код на хостах: все действия выполняет агент `config_server`.
|
||||||
|
|
||||||
|
## Рекомендации оператору
|
||||||
|
|
||||||
|
1. Заведите **отдельные учётные записи**: read-only для мониторинга,
|
||||||
|
полную — только для деплой-агента.
|
||||||
|
2. Для мониторинга запускайте инстанс с `read_only: true`.
|
||||||
|
3. Держите секреты в окружении/`config.local.json`, не в `config.json`.
|
||||||
|
4. Включите подтверждения управляющих инструментов в MCP-клиенте.
|
||||||
|
5. Ограничьте сетевой доступ к `config_server` (VPN/private network); наружу
|
||||||
|
порт 5005 не публикуйте.
|
||||||
|
6. Об уязвимостях сообщайте в службу безопасности компании (не публикуйте в
|
||||||
|
общих трекерах).
|
||||||
|
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
# Справочник инструментов
|
||||||
|
|
||||||
|
36 инструментов, сгруппированы по фазам. Общие аргументы:
|
||||||
|
|
||||||
|
- `server` — алиас стенда из конфига (по умолчанию `default`). Необязателен.
|
||||||
|
- `app_id` — идентификатор сервиса (см. `list_applications`).
|
||||||
|
- `confirm` — `true` для разрушительных операций.
|
||||||
|
|
||||||
|
Обозначения: **R** — только чтение; **W** — изменяет; **W!** — изменяет и
|
||||||
|
требует `confirm="true"`.
|
||||||
|
|
||||||
|
## Наблюдение (observe)
|
||||||
|
|
||||||
|
| Инструмент | Тип | Аргументы | Что делает |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `server_health` | R | — | Живость `config_server` (`GET /health`). |
|
||||||
|
| `whoami` | R | — | Кто авторизован. |
|
||||||
|
| `server_info` | R | — | Версия API (capability-probe), адрес, режим `read_only`. Вызывать первым. |
|
||||||
|
| `list_applications` | R | `include_presence` | Список сервисов с хостами и статусами. |
|
||||||
|
| `get_application` | R | `app_id` | Детали: compose, версии, env-файлы, журнал миграций. |
|
||||||
|
| `get_config` | R | `app_id` | Текущие compose и env сервиса. |
|
||||||
|
| `env_files` | R | `app_id` | Имена текущих env-файлов. |
|
||||||
|
| `compose_version` | R | `version_id` | Содержимое версии compose. |
|
||||||
|
| `env_version` | R | `version_id` | Содержимое версии env-файла. |
|
||||||
|
| `env_versions` | R | `app_id`, `filename` | Все версии конкретного env-файла. |
|
||||||
|
| `list_tasks` | R | `host_ip`, `status`, `task_type`, `limit` | Задачи агентов (аудит, поиск failed). |
|
||||||
|
| `get_task` | R | `task_id`, `wait`, `tail`, `output_bytes` | Статус/вывод задачи; `wait=true` ждёт завершения. |
|
||||||
|
| `get_deployment_files` | R | — | schema, manifest, prebuild_vars, ip_overrides. |
|
||||||
|
| `ip_match` | R | — | Сопоставление IP схемы и агентов. |
|
||||||
|
| `release_job` | R | — | Статус фоновой выгрузки релиза. |
|
||||||
|
| `deployment_tasks` | R | — | Последние release-задачи (sync/start). |
|
||||||
|
| `stats` | R | — | Счётчики версий compose/env и логов миграций. |
|
||||||
|
| `service_map` | R | — | Карта хостов и сервисов. |
|
||||||
|
| `overview` | R | — | Сводка: health, stats, сервисы не в OK, failed-задачи. |
|
||||||
|
| `release_status` | R | — | Сводка развёртывания: job, задачи, приложения, IP. |
|
||||||
|
|
||||||
|
## Конфигурация (configure)
|
||||||
|
|
||||||
|
| Инструмент | Тип | Аргументы | Что делает |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `set_compose` | W | `app_id`, `content` | Задать compose, создать версию, отправить агенту. |
|
||||||
|
| `set_env` | W | `app_id`, `filename`, `content` | Задать env-файл, создать версию, отправить агенту. |
|
||||||
|
| `restore_compose` | W! | `version_id`, `confirm` | Восстановить версию compose. |
|
||||||
|
| `restore_env_version` | W! | `version_id`, `confirm` | Восстановить версию env-файла. |
|
||||||
|
| `update_config` | W | `app_id` | Повторно отправить текущий конфиг агенту. |
|
||||||
|
|
||||||
|
## Жизненный цикл (operate)
|
||||||
|
|
||||||
|
| Инструмент | Тип | Аргументы | Что делает |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `deploy` | W | `app_id`, `wait` | `docker-compose up -d`. |
|
||||||
|
| `restart` | W | `app_id`, `wait` | `down` + `up -d`. |
|
||||||
|
| `down` | W! | `app_id`, `confirm`, `wait` | `docker-compose down`. |
|
||||||
|
| `migrate` | W | `app_id`, `wait` | `docker-compose run migration`. |
|
||||||
|
| `update_front` | W! | `app_id`, `build_dir`, `confirm` | Загрузить сборку фронта (каталог внутри `front_roots`). |
|
||||||
|
|
||||||
|
## Развёртывание релиза (release)
|
||||||
|
|
||||||
|
| Инструмент | Тип | Аргументы | Что делает |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `save_deployment_files` | W | `schema`, `manifest`, `prebuild_vars`, `ip_overrides` | Сохранить файлы развёртывания (только переданные поля). |
|
||||||
|
| `seed_hosts` | W | — | Создать хосты из schema.json. |
|
||||||
|
| `fill_vars` | W | — | Заполнить prebuild_vars из схемы. |
|
||||||
|
| `download_release` | W! | `load_images`, `gitlab_token`, `confirm` | Выгрузить релиз; `load_images=true` — фон. |
|
||||||
|
| `start_services` | W | — | Создать задачи запуска сервисов по схеме. |
|
||||||
|
|
||||||
|
## Обслуживание (maintain)
|
||||||
|
|
||||||
|
| Инструмент | Тип | Аргументы | Что делает |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `cleanup_old_versions` | W! | `days`, `confirm` | Удалить версии/логи старше N дней. |
|
||||||
|
|
||||||
|
## Результаты и ошибки
|
||||||
|
|
||||||
|
- Успех — читаемый JSON.
|
||||||
|
- **Доменная ошибка** (неверный `app_id`, нет confirm, отказ сервера) возвращается
|
||||||
|
как результат инструмента с текстом — ассистент видит её и может исправить.
|
||||||
|
- **Инфраструктурная ошибка** (нет сети) — как ошибка протокола.
|
||||||
|
- Длинный вывод задачи обрезается (по умолчанию с сохранением хвоста — там
|
||||||
|
обычно причина сбоя); `get_task` принимает `output_bytes` и `tail`.
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"servers": [
|
||||||
|
{
|
||||||
|
"alias": "prod",
|
||||||
|
"base_url": "http://10.20.30.40:5005",
|
||||||
|
"username": "${OMNI_PROD_USER}",
|
||||||
|
"password": "${OMNI_PROD_PASSWORD}",
|
||||||
|
"readonly_username": "${OMNI_PROD_RO_USER}",
|
||||||
|
"readonly_password": "${OMNI_PROD_RO_PASSWORD}",
|
||||||
|
"front_roots": ["/srv/omni-front-builds"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"alias": "stage",
|
||||||
|
"base_url": "http://10.20.31.40:5005",
|
||||||
|
"username": "${OMNI_STAGE_USER}",
|
||||||
|
"password": "${OMNI_STAGE_PASSWORD}"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"default": "prod",
|
||||||
|
"read_only": false,
|
||||||
|
"allow_hosts": ["10.20.30.40", "10.20.31.40"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"servers": [
|
||||||
|
{
|
||||||
|
"alias": "prod",
|
||||||
|
"base_url": "http://10.20.30.40:5005",
|
||||||
|
"readonly_username": "${OMNI_RO_USER}",
|
||||||
|
"readonly_password": "${OMNI_RO_PASSWORD}"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"default": "prod",
|
||||||
|
"read_only": true
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"servers": [
|
||||||
|
{
|
||||||
|
"alias": "prod",
|
||||||
|
"base_url": "http://10.20.30.40:5005",
|
||||||
|
"username": "${OMNI_USER}",
|
||||||
|
"password": "${OMNI_PASSWORD}",
|
||||||
|
"front_roots": ["/srv/omni-front-builds"]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"default": "prod",
|
||||||
|
"read_only": false
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# Демо и e2e
|
||||||
|
|
||||||
|
Два сценария для локальной проверки без прода.
|
||||||
|
|
||||||
|
## 1. Сквозной пример через MCP (`demo.py`)
|
||||||
|
|
||||||
|
Поднимает только `config_server`; задачи создаются, но их никто не исполняет —
|
||||||
|
показывает вызовы инструментов и ответы.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f examples/demo/docker-compose.yml up -d
|
||||||
|
go build -o omnichannel-mcp .
|
||||||
|
python3 examples/demo/demo.py
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. Полный e2e деплоя с агентом (`e2e.py`)
|
||||||
|
|
||||||
|
Дополнительно запускает настоящий **config-agent**, который опрашивает
|
||||||
|
`config_server` и реально выполняет задачи через `docker-compose`. Проверяется
|
||||||
|
жизненный цикл: `set_env` → `set_compose` → `deploy` → `restart` → `down`, а
|
||||||
|
также защита (`down` без `confirm` отклоняется).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f examples/demo/docker-compose.yml up -d
|
||||||
|
go build -o omnichannel-mcp .
|
||||||
|
python3 examples/demo/e2e.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Что делает `e2e.py`:
|
||||||
|
|
||||||
|
1. создаёт каталог `/tmp/omni-e2e/opt/demo_e2e` с `docker-compose.yml`;
|
||||||
|
2. поднимает контейнер `omni-e2e-agent` (образ `config-agent:v1.1.0`) с
|
||||||
|
проброшенным `docker.sock` и `/opt`;
|
||||||
|
3. ждёт регистрации приложения агентом;
|
||||||
|
4. гоняет жизненный цикл через MCP и проверяет состояние контейнеров;
|
||||||
|
5. удаляет тестовый агент.
|
||||||
|
|
||||||
|
Требуется доступ к `registry.devlexicom.ru` (образ агента).
|
||||||
|
|
||||||
|
## Очистка
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f examples/demo/docker-compose.yml down -v
|
||||||
|
docker rm -f omni-e2e-agent 2>/dev/null; docker network rm demo_e2e_default 2>/dev/null
|
||||||
|
rm -rf /tmp/omni-e2e
|
||||||
|
```
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"servers": [
|
||||||
|
{
|
||||||
|
"alias": "demo",
|
||||||
|
"base_url": "http://127.0.0.1:5005",
|
||||||
|
"username": "admin",
|
||||||
|
"password": "LexP@ssw0rd_1"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"default": "demo",
|
||||||
|
"read_only": false,
|
||||||
|
"allow_hosts": ["127.0.0.1"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Сквозной демо-пример использования omnichannel-mcp.
|
||||||
|
|
||||||
|
Что делает:
|
||||||
|
1) засевает демо-сервис в локальный config_server (агентский эндпоинт);
|
||||||
|
2) запускает omnichannel-mcp по stdio;
|
||||||
|
3) выполняет типичный сценарий эксплуатации/деплоя:
|
||||||
|
server_info -> list_applications -> get_application -> set_env ->
|
||||||
|
set_compose -> deploy -> get_task -> overview.
|
||||||
|
|
||||||
|
Зависимостей нет (только stdlib). Требуется поднятый стенд:
|
||||||
|
docker compose -f examples/demo/docker-compose.yml up -d
|
||||||
|
и собранный бинарник:
|
||||||
|
go build -o omnichannel-mcp .
|
||||||
|
|
||||||
|
Запуск: python3 examples/demo/demo.py
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import select
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
ROOT = os.path.dirname(os.path.dirname(HERE)) # корень репозитория
|
||||||
|
BINARY = os.path.join(ROOT, "omnichannel-mcp")
|
||||||
|
CONFIG = os.path.join(HERE, "config.demo.json")
|
||||||
|
|
||||||
|
BASE_URL = "http://127.0.0.1:5005"
|
||||||
|
AGENT_SECRET = "demo-agent-secret"
|
||||||
|
DEMO_APP_ID = 1
|
||||||
|
|
||||||
|
|
||||||
|
def http_json(method, path, payload=None):
|
||||||
|
data = json.dumps(payload).encode() if payload is not None else None
|
||||||
|
req = urllib.request.Request(BASE_URL + path, data=data, method=method,
|
||||||
|
headers={"Content-Type": "application/json"})
|
||||||
|
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||||
|
return json.loads(resp.read().decode())
|
||||||
|
|
||||||
|
|
||||||
|
def seed_demo_app():
|
||||||
|
"""Регистрирует демо-сервис так, как это делает агент ноды."""
|
||||||
|
body = {
|
||||||
|
"secret": AGENT_SECRET,
|
||||||
|
"name": "demo_web",
|
||||||
|
"host_ip": "10.0.0.10",
|
||||||
|
"hostname": "demo-node",
|
||||||
|
"path": "/opt/omnichannel/demo_web",
|
||||||
|
"status": "OK",
|
||||||
|
"compose_content": "services:\n web:\n image: nginx:1.25\n",
|
||||||
|
"env_files": {".env": "FOO=bar\n"},
|
||||||
|
}
|
||||||
|
result = http_json("POST", "/api/register", body)
|
||||||
|
print(f"засеян демо-сервис demo_web: app_id={result.get('app_id')}")
|
||||||
|
|
||||||
|
|
||||||
|
class MCP:
|
||||||
|
"""Минимальный MCP-клиент по stdio (JSON-RPC, newline-delimited)."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
env = dict(os.environ, OMNI_LOG_LEVEL="warn")
|
||||||
|
self.proc = subprocess.Popen(
|
||||||
|
[BINARY, "-config", CONFIG], cwd=ROOT, env=env,
|
||||||
|
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
||||||
|
text=True, bufsize=1,
|
||||||
|
)
|
||||||
|
self.id = 0
|
||||||
|
self._request("initialize", {
|
||||||
|
"protocolVersion": "2025-06-18", "capabilities": {},
|
||||||
|
"clientInfo": {"name": "demo", "version": "0"},
|
||||||
|
})
|
||||||
|
self._notify("notifications/initialized", {})
|
||||||
|
|
||||||
|
def _id(self):
|
||||||
|
self.id += 1
|
||||||
|
return self.id
|
||||||
|
|
||||||
|
def _send(self, obj):
|
||||||
|
self.proc.stdin.write(json.dumps(obj) + "\n")
|
||||||
|
self.proc.stdin.flush()
|
||||||
|
|
||||||
|
def _recv(self, want_id, timeout=30):
|
||||||
|
while True:
|
||||||
|
ready, _, _ = select.select([self.proc.stdout], [], [], timeout)
|
||||||
|
if not ready:
|
||||||
|
raise TimeoutError(f"нет ответа на id={want_id}")
|
||||||
|
line = self.proc.stdout.readline()
|
||||||
|
if not line:
|
||||||
|
raise RuntimeError("сервер закрыл stdout:\n" + self.proc.stderr.read())
|
||||||
|
msg = json.loads(line)
|
||||||
|
if msg.get("id") == want_id:
|
||||||
|
return msg
|
||||||
|
|
||||||
|
def _request(self, method, params):
|
||||||
|
rid = self._id()
|
||||||
|
self._send({"jsonrpc": "2.0", "id": rid, "method": method, "params": params})
|
||||||
|
return self._recv(rid)
|
||||||
|
|
||||||
|
def _notify(self, method, params):
|
||||||
|
self._send({"jsonrpc": "2.0", "method": method, "params": params})
|
||||||
|
|
||||||
|
def tools(self):
|
||||||
|
return self._request("tools/list", {})["result"]["tools"]
|
||||||
|
|
||||||
|
def call(self, name, arguments):
|
||||||
|
resp = self._request("tools/call", {"name": name, "arguments": arguments})
|
||||||
|
result = resp.get("result", {})
|
||||||
|
text = "".join(c.get("text", "") for c in result.get("content", []))
|
||||||
|
return text, result.get("isError", False)
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
self.proc.stdin.close()
|
||||||
|
self.proc.terminate()
|
||||||
|
|
||||||
|
|
||||||
|
def show(title, name, arguments, mcp):
|
||||||
|
text, is_error = mcp.call(name, arguments)
|
||||||
|
print(f"\n=== {title} ({'ошибка' if is_error else 'ok'}) ===")
|
||||||
|
print(text[:600])
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
seed_demo_app()
|
||||||
|
|
||||||
|
mcp = MCP()
|
||||||
|
tools = mcp.tools()
|
||||||
|
print(f"\nMCP-сервер отдал инструментов: {len(tools)}")
|
||||||
|
|
||||||
|
show("Версия API и режим", "server_info", {}, mcp)
|
||||||
|
show("Список сервисов", "list_applications", {}, mcp)
|
||||||
|
show("Детали демо-сервиса", "get_application", {"app_id": DEMO_APP_ID}, mcp)
|
||||||
|
|
||||||
|
show("Обновляем env", "set_env",
|
||||||
|
{"app_id": DEMO_APP_ID, "filename": ".env", "content": "FOO=baz\n"}, mcp)
|
||||||
|
show("Обновляем compose", "set_compose",
|
||||||
|
{"app_id": DEMO_APP_ID, "content": "services:\n web:\n image: nginx:1.27\n"}, mcp)
|
||||||
|
|
||||||
|
deploy_text, _ = mcp.call("deploy", {"app_id": DEMO_APP_ID})
|
||||||
|
print("\n=== Запуск сервиса (deploy) ===")
|
||||||
|
print(deploy_text[:400])
|
||||||
|
|
||||||
|
try:
|
||||||
|
task_id = json.loads(deploy_text)["task_id"]
|
||||||
|
show("Статус задачи деплоя", "get_task", {"task_id": task_id}, mcp)
|
||||||
|
except (ValueError, KeyError):
|
||||||
|
print("(не удалось извлечь task_id — пропускаем get_task)")
|
||||||
|
|
||||||
|
show("Сводка состояния стенда", "overview", {}, mcp)
|
||||||
|
|
||||||
|
mcp.close()
|
||||||
|
print("\nDemo завершён.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
if not os.path.exists(BINARY):
|
||||||
|
sys.exit(f"нет бинарника {BINARY}: сначала выполните `go build -o omnichannel-mcp .`")
|
||||||
|
main()
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
# Демонстрационный стенд config_server для локальной проверки omnichannel-mcp.
|
||||||
|
#
|
||||||
|
# Поднимает config_server v1.1.0 + PostgreSQL. Данные — эфемерные.
|
||||||
|
#
|
||||||
|
# docker compose -f examples/demo/docker-compose.yml up -d
|
||||||
|
# curl -s http://localhost:5005/health # -> OK
|
||||||
|
# python3 examples/demo/demo.py # сквозной пример деплоя через MCP
|
||||||
|
# docker compose -f examples/demo/docker-compose.yml down -v # удалить и данные
|
||||||
|
#
|
||||||
|
# init-db.sh — схема БД и учётная запись admin/LexP@ssw0rd_1 (из поставки
|
||||||
|
# config_server). Для демо этого достаточно.
|
||||||
|
|
||||||
|
name: omni-mcp-demo
|
||||||
|
|
||||||
|
services:
|
||||||
|
db:
|
||||||
|
image: registry.devlexicom.ru/templates/docker/docker_images/postgres:15
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: config_server
|
||||||
|
POSTGRES_USER: config_user
|
||||||
|
POSTGRES_PASSWORD: 8fj439fjdk48
|
||||||
|
TZ: Europe/Moscow
|
||||||
|
PGTZ: Europe/Moscow
|
||||||
|
volumes:
|
||||||
|
- db_data:/var/lib/postgresql/data
|
||||||
|
- ./init-db.sh:/docker-entrypoint-initdb.d/init-db.sh:ro
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U config_user -d config_server"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
|
||||||
|
config-server:
|
||||||
|
image: registry.devlexicom.ru/omnichannel_platform/config_server/config-server:v1.1.0
|
||||||
|
ports:
|
||||||
|
- "5005:5000"
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: config_server
|
||||||
|
POSTGRES_USER: config_user
|
||||||
|
POSTGRES_PASSWORD: 8fj439fjdk48
|
||||||
|
POSTGRES_HOST: db
|
||||||
|
POSTGRES_PORT: "5432"
|
||||||
|
SECRET_KEY: demo-secret-key
|
||||||
|
AGENT_SECRET: demo-agent-secret
|
||||||
|
DEPLOYMENT_DATA_DIR: /data/deployment
|
||||||
|
TZ: Europe/Moscow
|
||||||
|
volumes:
|
||||||
|
- server_data:/data/deployment
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
db_data:
|
||||||
|
server_data:
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""E2E-тест деплоя omnichannel-mcp с реальным config-agent.
|
||||||
|
|
||||||
|
Поднимает контейнер config-agent, который опрашивает config_server и реально
|
||||||
|
выполняет задачи через docker-compose, затем гоняет жизненный цикл сервиса
|
||||||
|
через MCP: set_env -> set_compose -> deploy -> restart -> down.
|
||||||
|
|
||||||
|
Требуется:
|
||||||
|
- поднятый стенд: docker compose -f examples/demo/docker-compose.yml up -d
|
||||||
|
- собранный бинарник: go build -o omnichannel-mcp .
|
||||||
|
- доступ к образу config-agent (registry.devlexicom.ru).
|
||||||
|
|
||||||
|
Запуск: python3 examples/demo/e2e.py
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
sys.path.insert(0, HERE)
|
||||||
|
from demo import MCP # noqa: E402 (общий минимальный MCP-клиент)
|
||||||
|
|
||||||
|
AGENT_IMAGE = "registry.devlexicom.ru/omnichannel_platform/config_server/config-agent:v1.1.0"
|
||||||
|
AGENT_NAME = "omni-e2e-agent"
|
||||||
|
NETWORK = "omni-mcp-demo_default"
|
||||||
|
OPT_DIR = "/tmp/omni-e2e/opt"
|
||||||
|
APP = "demo_e2e"
|
||||||
|
APP_DIR = os.path.join(OPT_DIR, APP)
|
||||||
|
HOST_IP = "10.0.0.10"
|
||||||
|
|
||||||
|
INITIAL_COMPOSE = """services:
|
||||||
|
web:
|
||||||
|
image: alpine:3.20
|
||||||
|
command: ["sleep", "3600"]
|
||||||
|
"""
|
||||||
|
UPDATED_COMPOSE = """services:
|
||||||
|
web:
|
||||||
|
image: alpine:3.20
|
||||||
|
command: ["sleep", "7200"]
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def sh(*args, check=True):
|
||||||
|
return subprocess.run(args, capture_output=True, text=True, check=check)
|
||||||
|
|
||||||
|
|
||||||
|
def docker(*args, check=True):
|
||||||
|
return sh("docker", *args, check=check)
|
||||||
|
|
||||||
|
|
||||||
|
def prepare_app_dir():
|
||||||
|
os.makedirs(APP_DIR, exist_ok=True)
|
||||||
|
with open(os.path.join(APP_DIR, "docker-compose.yml"), "w") as fh:
|
||||||
|
fh.write(INITIAL_COMPOSE)
|
||||||
|
|
||||||
|
|
||||||
|
def start_agent():
|
||||||
|
docker("rm", "-f", AGENT_NAME, check=False)
|
||||||
|
docker(
|
||||||
|
"run", "-d", "--name", AGENT_NAME,
|
||||||
|
"--network", NETWORK,
|
||||||
|
"-v", "/var/run/docker.sock:/var/run/docker.sock",
|
||||||
|
"-v", "/usr/bin/docker:/usr/bin/docker:ro",
|
||||||
|
"-v", f"{OPT_DIR}:/opt",
|
||||||
|
"-e", "SERVER_URL=http://config-server:5000",
|
||||||
|
"-e", "AGENT_SECRET=demo-agent-secret",
|
||||||
|
"-e", f"AGENT_REGISTER_IP={HOST_IP}",
|
||||||
|
"-e", "TASK_CHECK_INTERVAL=3",
|
||||||
|
"-e", "SCAN_INTERVAL=15",
|
||||||
|
AGENT_IMAGE,
|
||||||
|
)
|
||||||
|
print(f"config-agent запущен: {AGENT_NAME}")
|
||||||
|
|
||||||
|
|
||||||
|
def stop_agent():
|
||||||
|
docker("rm", "-f", AGENT_NAME, check=False)
|
||||||
|
print("config-agent остановлен")
|
||||||
|
|
||||||
|
|
||||||
|
def find_app(mcp, timeout=60):
|
||||||
|
deadline = time.time() + timeout
|
||||||
|
while time.time() < deadline:
|
||||||
|
text, _ = mcp.call("list_applications", {})
|
||||||
|
try:
|
||||||
|
apps = json.loads(text)
|
||||||
|
except ValueError:
|
||||||
|
apps = []
|
||||||
|
for app in apps:
|
||||||
|
if app.get("name") == APP:
|
||||||
|
return app["id"]
|
||||||
|
time.sleep(2)
|
||||||
|
raise RuntimeError(f"агент не зарегистрировал приложение {APP} за {timeout}s")
|
||||||
|
|
||||||
|
|
||||||
|
def wait_task(mcp, task_id, timeout=120):
|
||||||
|
deadline = time.time() + timeout
|
||||||
|
while time.time() < deadline:
|
||||||
|
text, _ = mcp.call("get_task", {"task_id": task_id})
|
||||||
|
task = json.loads(text)
|
||||||
|
if task["status"] in ("completed", "failed"):
|
||||||
|
return task
|
||||||
|
time.sleep(2)
|
||||||
|
raise RuntimeError(f"задача {task_id} не завершилась за {timeout}s")
|
||||||
|
|
||||||
|
|
||||||
|
def call_json(mcp, tool, args):
|
||||||
|
text, is_error = mcp.call(tool, args)
|
||||||
|
if is_error:
|
||||||
|
raise RuntimeError(f"{tool}: {text}")
|
||||||
|
return json.loads(text)
|
||||||
|
|
||||||
|
|
||||||
|
def container_running(app):
|
||||||
|
out = docker("ps", "--filter", f"name={app}", "--format", "{{.Names}}").stdout
|
||||||
|
return app in out
|
||||||
|
|
||||||
|
|
||||||
|
def wait_file_contains(path, needle, timeout=40):
|
||||||
|
deadline = time.time() + timeout
|
||||||
|
while time.time() < deadline:
|
||||||
|
try:
|
||||||
|
if needle in open(path).read():
|
||||||
|
return True
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
time.sleep(2)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
CHECKS = []
|
||||||
|
|
||||||
|
|
||||||
|
def check(name, ok):
|
||||||
|
CHECKS.append((name, ok))
|
||||||
|
print(f" [{'PASS' if ok else 'FAIL'}] {name}")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
mcp = MCP()
|
||||||
|
try:
|
||||||
|
app_id = find_app(mcp)
|
||||||
|
print(f"Приложение найдено: {APP} (app_id={app_id})")
|
||||||
|
|
||||||
|
# 1. Изменение конфигурации через MCP и доставка агентом.
|
||||||
|
call_json(mcp, "set_env", {"app_id": app_id, "filename": ".env", "content": "E2E=1\n"})
|
||||||
|
call_json(mcp, "set_compose", {"app_id": app_id, "content": UPDATED_COMPOSE})
|
||||||
|
check("set_env/set_compose приняты сервером", True)
|
||||||
|
check("compose доставлен агентом на диск",
|
||||||
|
wait_file_contains(os.path.join(APP_DIR, "docker-compose.yml"), "7200"))
|
||||||
|
|
||||||
|
# 2. Deploy.
|
||||||
|
ack = call_json(mcp, "deploy", {"app_id": app_id, "wait": True})
|
||||||
|
task = ack.get("task", {})
|
||||||
|
check("deploy завершён (completed)", task.get("status") == "completed")
|
||||||
|
check("контейнер demo_e2e поднят", container_running(APP))
|
||||||
|
print(f" вывод deploy: {task.get('output','')[:160].strip()}")
|
||||||
|
|
||||||
|
# 3. Restart.
|
||||||
|
ack = call_json(mcp, "restart", {"app_id": app_id, "wait": True})
|
||||||
|
check("restart завершён (completed)", ack.get("task", {}).get("status") == "completed")
|
||||||
|
check("контейнер жив после restart", container_running(APP))
|
||||||
|
|
||||||
|
# 4. Down.
|
||||||
|
ack = call_json(mcp, "down", {"app_id": app_id, "confirm": True, "wait": True})
|
||||||
|
check("down завершён (completed)", ack.get("task", {}).get("status") == "completed")
|
||||||
|
time.sleep(2)
|
||||||
|
check("контейнер остановлен", not container_running(APP))
|
||||||
|
|
||||||
|
# 5. Защита без confirm.
|
||||||
|
text, is_error = mcp.call("down", {"app_id": app_id})
|
||||||
|
check("down без confirm отклонён", is_error)
|
||||||
|
|
||||||
|
finally:
|
||||||
|
mcp.close()
|
||||||
|
stop_agent()
|
||||||
|
|
||||||
|
failed = [name for name, ok in CHECKS if not ok]
|
||||||
|
print(f"\nE2E: {len(CHECKS) - len(failed)}/{len(CHECKS)} проверок пройдено")
|
||||||
|
if failed:
|
||||||
|
print("Провалено: " + "; ".join(failed))
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
prepare_app_dir()
|
||||||
|
start_agent()
|
||||||
|
main()
|
||||||
@@ -0,0 +1,272 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
ADMIN_LOGIN=${ADMIN_LOGIN:-admin}
|
||||||
|
ADMIN_PASSWORD=${ADMIN_PASSWORD:-LexP@ssw0rd_1}
|
||||||
|
|
||||||
|
# Ждем пока PostgreSQL будет готов принимать подключения
|
||||||
|
until pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"; do
|
||||||
|
echo "Waiting for PostgreSQL to be ready..."
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
|
||||||
|
# Создаем базу данных и пользователя если не существуют
|
||||||
|
psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-EOSQL
|
||||||
|
-- Создаем расширение если не существует
|
||||||
|
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||||
|
|
||||||
|
-- Даем все права пользователю на базу данных
|
||||||
|
GRANT ALL PRIVILEGES ON DATABASE $POSTGRES_DB TO $POSTGRES_USER;
|
||||||
|
|
||||||
|
-- Создаем таблицы если их нет (для первоначальной инициализации)
|
||||||
|
DO \$\$
|
||||||
|
BEGIN
|
||||||
|
-- Таблица приложений
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'application') THEN
|
||||||
|
CREATE TABLE application (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
name VARCHAR(100) NOT NULL,
|
||||||
|
host_ip VARCHAR(45) NOT NULL,
|
||||||
|
hostname VARCHAR(100) NOT NULL,
|
||||||
|
path VARCHAR(500) NOT NULL,
|
||||||
|
active BOOLEAN DEFAULT TRUE,
|
||||||
|
status VARCHAR(20) DEFAULT 'UNKNOWN',
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Индексы для application
|
||||||
|
CREATE INDEX idx_application_host_ip ON application(host_ip);
|
||||||
|
CREATE INDEX idx_application_active ON application(active);
|
||||||
|
CREATE INDEX idx_application_status ON application(status);
|
||||||
|
CREATE INDEX idx_application_created_at ON application(created_at);
|
||||||
|
|
||||||
|
RAISE NOTICE 'Table application created successfully';
|
||||||
|
ELSE
|
||||||
|
RAISE NOTICE 'Table application already exists';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Гарантируем наличие колонки status в application
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_name = 'application' AND column_name = 'status'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE application ADD COLUMN status VARCHAR(20) NOT NULL DEFAULT 'UNKNOWN';
|
||||||
|
UPDATE application SET status = 'UNKNOWN' WHERE status IS NULL;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_application_status ON application(status);
|
||||||
|
RAISE NOTICE 'Added status column to application table';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Таблица docker-compose файлов
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'compose_file') THEN
|
||||||
|
CREATE TABLE compose_file (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
application_id INTEGER NOT NULL REFERENCES application(id) ON DELETE CASCADE,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
version INTEGER NOT NULL DEFAULT 1,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
is_current BOOLEAN DEFAULT TRUE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Индексы для compose_file
|
||||||
|
CREATE INDEX idx_compose_file_application_id ON compose_file(application_id);
|
||||||
|
CREATE INDEX idx_compose_file_is_current ON compose_file(is_current);
|
||||||
|
CREATE INDEX idx_compose_file_created_at ON compose_file(created_at);
|
||||||
|
|
||||||
|
RAISE NOTICE 'Table compose_file created successfully';
|
||||||
|
ELSE
|
||||||
|
RAISE NOTICE 'Table compose_file already exists';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Таблица env файлов (обновленная структура)
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'env_file') THEN
|
||||||
|
CREATE TABLE env_file (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
application_id INTEGER NOT NULL REFERENCES application(id) ON DELETE CASCADE,
|
||||||
|
filename VARCHAR(100) NOT NULL DEFAULT '.env',
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
version INTEGER NOT NULL DEFAULT 1,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
is_current BOOLEAN DEFAULT TRUE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Уникальный constraint ТОЛЬКО для текущих версий (используем partial unique index)
|
||||||
|
CREATE UNIQUE INDEX unique_current_env ON env_file (application_id, filename)
|
||||||
|
WHERE is_current = true;
|
||||||
|
|
||||||
|
-- Индексы для env_file
|
||||||
|
CREATE INDEX idx_env_file_application_id ON env_file(application_id);
|
||||||
|
CREATE INDEX idx_env_file_filename ON env_file(filename);
|
||||||
|
CREATE INDEX idx_env_file_is_current ON env_file(is_current);
|
||||||
|
CREATE INDEX idx_env_file_created_at ON env_file(created_at);
|
||||||
|
|
||||||
|
RAISE NOTICE 'Table env_file created successfully';
|
||||||
|
ELSE
|
||||||
|
-- Если таблица уже существует, проверяем наличие поля filename
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_name = 'env_file' AND column_name = 'filename') THEN
|
||||||
|
-- Добавляем поле filename
|
||||||
|
ALTER TABLE env_file ADD COLUMN filename VARCHAR(100) NOT NULL DEFAULT '.env';
|
||||||
|
|
||||||
|
-- Создаем индексы для нового поля
|
||||||
|
CREATE INDEX idx_env_file_filename ON env_file(filename);
|
||||||
|
|
||||||
|
RAISE NOTICE 'Added filename column to env_file table';
|
||||||
|
ELSE
|
||||||
|
RAISE NOTICE 'Table env_file already exists with filename column';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Удаляем старый constraint если он существует
|
||||||
|
IF EXISTS (SELECT 1 FROM information_schema.table_constraints
|
||||||
|
WHERE table_name = 'env_file' AND constraint_name = 'unique_current_env') THEN
|
||||||
|
ALTER TABLE env_file DROP CONSTRAINT unique_current_env;
|
||||||
|
RAISE NOTICE 'Dropped old unique_current_env constraint';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Удаляем старый index если он существует
|
||||||
|
IF EXISTS (SELECT 1 FROM pg_indexes
|
||||||
|
WHERE tablename = 'env_file' AND indexname = 'unique_current_env') THEN
|
||||||
|
DROP INDEX unique_current_env;
|
||||||
|
RAISE NOTICE 'Dropped old unique_current_env index';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Создаем новый partial unique index
|
||||||
|
CREATE UNIQUE INDEX unique_current_env ON env_file (application_id, filename)
|
||||||
|
WHERE is_current = true;
|
||||||
|
|
||||||
|
RAISE NOTICE 'Created new partial unique index for current env files';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Таблица логов миграций
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'migration_log') THEN
|
||||||
|
CREATE TABLE migration_log (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
application_id INTEGER NOT NULL REFERENCES application(id) ON DELETE CASCADE,
|
||||||
|
command VARCHAR(500) NOT NULL,
|
||||||
|
status VARCHAR(50) NOT NULL,
|
||||||
|
output TEXT,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Индексы для migration_log
|
||||||
|
CREATE INDEX idx_migration_log_application_id ON migration_log(application_id);
|
||||||
|
CREATE INDEX idx_migration_log_created_at ON migration_log(created_at);
|
||||||
|
|
||||||
|
RAISE NOTICE 'Table migration_log created successfully';
|
||||||
|
ELSE
|
||||||
|
RAISE NOTICE 'Table migration_log already exists';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Таблица задач агента
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'agent_task') THEN
|
||||||
|
CREATE TABLE agent_task (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
task_id VARCHAR(36) UNIQUE NOT NULL,
|
||||||
|
application_id INTEGER NOT NULL REFERENCES application(id) ON DELETE CASCADE,
|
||||||
|
task_type VARCHAR(50) NOT NULL,
|
||||||
|
command VARCHAR(500) NOT NULL,
|
||||||
|
status VARCHAR(20) NOT NULL DEFAULT 'pending',
|
||||||
|
host_ip VARCHAR(45) NOT NULL,
|
||||||
|
payload TEXT,
|
||||||
|
output TEXT,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
completed_at TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Создаем индексы для часто используемых полей
|
||||||
|
CREATE INDEX idx_agent_task_task_id ON agent_task(task_id);
|
||||||
|
CREATE INDEX idx_agent_task_application_id ON agent_task(application_id);
|
||||||
|
CREATE INDEX idx_agent_task_status ON agent_task(status);
|
||||||
|
CREATE INDEX idx_agent_task_host_ip ON agent_task(host_ip);
|
||||||
|
CREATE INDEX idx_agent_task_created_at ON agent_task(created_at);
|
||||||
|
CREATE INDEX idx_agent_task_completed_at ON agent_task(completed_at);
|
||||||
|
|
||||||
|
-- Комментарии к таблице и полям
|
||||||
|
COMMENT ON TABLE agent_task IS 'Таблица для хранения задач агента развертывания';
|
||||||
|
COMMENT ON COLUMN agent_task.task_id IS 'Уникальный идентификатор задачи (UUID)';
|
||||||
|
COMMENT ON COLUMN agent_task.task_type IS 'Тип задачи: deploy, migration, update_config';
|
||||||
|
COMMENT ON COLUMN agent_task.status IS 'Статус задачи: pending, in_progress, completed, failed';
|
||||||
|
COMMENT ON COLUMN agent_task.payload IS 'Дополнительные данные для задачи в формате JSON';
|
||||||
|
COMMENT ON COLUMN agent_task.output IS 'Вывод выполнения команды';
|
||||||
|
|
||||||
|
RAISE NOTICE 'Table agent_task created successfully';
|
||||||
|
ELSE
|
||||||
|
RAISE NOTICE 'Table agent_task already exists';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Таблица пользователей веб-интерфейса
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'auth_user') THEN
|
||||||
|
CREATE TABLE auth_user (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
username VARCHAR(100) UNIQUE NOT NULL,
|
||||||
|
password_hash VARCHAR(128) NOT NULL,
|
||||||
|
is_active BOOLEAN DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_auth_user_username ON auth_user(username);
|
||||||
|
CREATE INDEX idx_auth_user_active ON auth_user(is_active);
|
||||||
|
RAISE NOTICE 'Table auth_user created successfully';
|
||||||
|
ELSE
|
||||||
|
RAISE NOTICE 'Table auth_user already exists';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Обновляем длину поля path в application если нужно
|
||||||
|
IF EXISTS (SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_name = 'application' AND column_name = 'path'
|
||||||
|
AND character_maximum_length < 500) THEN
|
||||||
|
ALTER TABLE application ALTER COLUMN path TYPE VARCHAR(500);
|
||||||
|
RAISE NOTICE 'Updated application.path column length to 500';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Обновляем длину поля command в migration_log если нужно
|
||||||
|
IF EXISTS (SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_name = 'migration_log' AND column_name = 'command'
|
||||||
|
AND character_maximum_length < 500) THEN
|
||||||
|
ALTER TABLE migration_log ALTER COLUMN command TYPE VARCHAR(500);
|
||||||
|
RAISE NOTICE 'Updated migration_log.command column length to 500';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Обновляем длину поля command в agent_task если нужно
|
||||||
|
IF EXISTS (SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_name = 'agent_task' AND column_name = 'command'
|
||||||
|
AND character_maximum_length < 500) THEN
|
||||||
|
ALTER TABLE agent_task ALTER COLUMN command TYPE VARCHAR(500);
|
||||||
|
RAISE NOTICE 'Updated agent_task.command column length to 500';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
END
|
||||||
|
\$\$;
|
||||||
|
|
||||||
|
-- Создаем функцию для автоматического обновления updated_at
|
||||||
|
CREATE OR REPLACE FUNCTION update_updated_at_column()
|
||||||
|
RETURNS TRIGGER AS \$\$
|
||||||
|
BEGIN
|
||||||
|
NEW.updated_at = CURRENT_TIMESTAMP;
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
\$\$ language 'plpgsql';
|
||||||
|
|
||||||
|
-- Создаем триггер для автоматического обновления updated_at в application
|
||||||
|
DROP TRIGGER IF EXISTS update_application_updated_at ON application;
|
||||||
|
CREATE TRIGGER update_application_updated_at
|
||||||
|
BEFORE UPDATE ON application
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION update_updated_at_column();
|
||||||
|
|
||||||
|
-- Анализируем таблицы для оптимизации
|
||||||
|
ANALYZE application;
|
||||||
|
ANALYZE compose_file;
|
||||||
|
ANALYZE env_file;
|
||||||
|
ANALYZE migration_log;
|
||||||
|
ANALYZE agent_task;
|
||||||
|
ANALYZE auth_user;
|
||||||
|
|
||||||
|
-- Инициализация единственной учетной записи (логин/пароль задаются env ADMIN_LOGIN/ADMIN_PASSWORD)
|
||||||
|
INSERT INTO auth_user (username, password_hash, is_active)
|
||||||
|
VALUES ('$ADMIN_LOGIN', md5('$ADMIN_PASSWORD'), TRUE)
|
||||||
|
ON CONFLICT (username) DO UPDATE
|
||||||
|
SET password_hash = EXCLUDED.password_hash,
|
||||||
|
is_active = TRUE;
|
||||||
|
EOSQL
|
||||||
|
|
||||||
|
echo "Database initialization completed!"
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
# Примеры промптов
|
||||||
|
|
||||||
|
Готовые формулировки для оператора: что написать агенту, чтобы он через
|
||||||
|
`omnichannel-mcp` выполнил типовую задачу. В скобках — инструменты, которые
|
||||||
|
агент вызовет. Замените хост/имена/пути на свои.
|
||||||
|
|
||||||
|
## Наблюдение и диагностика
|
||||||
|
|
||||||
|
- «Покажи, что сейчас происходит на стенде `prod`.» → `overview`
|
||||||
|
- «Какие сервисы не в статусе OK?» → `list_applications` (или `overview`)
|
||||||
|
- «Покажи детали сервиса `user_system_front`.» → `get_application`
|
||||||
|
- «Покажи последние упавшие задачи за сегодня.» → `list_tasks {status:"failed"}`
|
||||||
|
- «Покажи хвост вывода задачи `0c9f...`.» → `get_task {output_bytes:4000}`
|
||||||
|
- «Все ли IP из схемы сопоставлены агентам?» → `ip_match`
|
||||||
|
|
||||||
|
## Быстрый деплой на 1 сервер
|
||||||
|
|
||||||
|
- «Задеплой сервис `demo_web`: обнови compose на образ `nginx:1.27`, подними и
|
||||||
|
дождись результата.» →
|
||||||
|
`set_compose` → `deploy` → `get_task {wait:true}` → `overview`
|
||||||
|
- «Перезапусти сервис `user_system`, затем проверь, что задачи прошли без
|
||||||
|
ошибок.» → `restart` → `list_tasks`
|
||||||
|
|
||||||
|
## Деплой релиза на несколько серверов
|
||||||
|
|
||||||
|
- «Загрузи релиз на все хосты схемы: сохрани schema.json и manifest.json,
|
||||||
|
разложи хосты, скачай релиз с образами, дождись завершения и запусти
|
||||||
|
сервисы.» →
|
||||||
|
`save_deployment_files` → `seed_hosts` → `download_release {load_images:true, confirm:true}`
|
||||||
|
→ `release_job` (опрос) → `start_services` → `deployment_tasks` → `get_task`
|
||||||
|
|
||||||
|
## Обновление фронта
|
||||||
|
|
||||||
|
- «Обнови фронт сервиса `chat_widget` из каталога `/srv/builds/chat_widget`.» →
|
||||||
|
`update_front {app_id:…, build_dir:"/srv/builds/chat_widget", confirm:true}`
|
||||||
|
→ `get_task {wait:true}`
|
||||||
|
|
||||||
|
## Откат и обслуживание
|
||||||
|
|
||||||
|
- «Откати compose сервиса до версии `12`.» →
|
||||||
|
`get_application` (найти id версии) → `restore_compose {version_id:12, confirm:true}`
|
||||||
|
- «Верни env-файл `.env` сервиса к версии `7`.» →
|
||||||
|
`env_versions` → `restore_env_version {version_id:7, confirm:true}`
|
||||||
|
- «Почисти версии конфигураций старше 180 дней.» →
|
||||||
|
`cleanup_old_versions {days:180, confirm:true}`
|
||||||
|
|
||||||
|
## Мультистенд
|
||||||
|
|
||||||
|
- «Покажи статус стендов `prod` и `stage` и сравни.» →
|
||||||
|
`overview {server:"prod"}` + `overview {server:"stage"}`
|
||||||
|
- «На `stage` откати последний неудачный деплой, на `prod` ничего не трогай.» →
|
||||||
|
работа с `server:"stage"`
|
||||||
|
|
||||||
|
> Опасные операции (`down`, `restore_*`, `update_front`, `download_release`,
|
||||||
|
> `cleanup_old_versions`) требуют `confirm="true"` — агент обязан спросить
|
||||||
|
> подтверждение явно.
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
module omnichannel-mcp
|
||||||
|
|
||||||
|
go 1.27.1
|
||||||
|
|
||||||
|
require (
|
||||||
|
git.totmin.ru/en2zmax/forge-toolkit v0.1.0
|
||||||
|
github.com/modelcontextprotocol/go-sdk v1.7.0
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/google/jsonschema-go v0.4.3 // indirect
|
||||||
|
github.com/segmentio/asm v1.1.3 // indirect
|
||||||
|
github.com/segmentio/encoding v0.5.4 // indirect
|
||||||
|
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
|
||||||
|
golang.org/x/oauth2 v0.35.0 // indirect
|
||||||
|
golang.org/x/sync v0.20.0 // indirect
|
||||||
|
golang.org/x/sys v0.41.0 // indirect
|
||||||
|
golang.org/x/time v0.15.0 // indirect
|
||||||
|
)
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
git.totmin.ru/en2zmax/forge-toolkit v0.1.0 h1:d4p1mDzPwG/CuehTXVlyeT0vAWkQmzHG83NLmqUxkAo=
|
||||||
|
git.totmin.ru/en2zmax/forge-toolkit v0.1.0/go.mod h1:4LVtO/yq0SsTP6opDYS32JiDNC/tBBIAwGLGGw/D88Q=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||||
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
|
github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0=
|
||||||
|
github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
|
||||||
|
github.com/modelcontextprotocol/go-sdk v1.7.0 h1:yqjY2dsbKAC0LSuWZVBMrHgiG8ukXv6NRo0JiALay44=
|
||||||
|
github.com/modelcontextprotocol/go-sdk v1.7.0/go.mod h1:dL7u98E/zjJTGzEq+j30jQ8K2k1mb6LeAH4inEcSGts=
|
||||||
|
github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc=
|
||||||
|
github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg=
|
||||||
|
github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0=
|
||||||
|
github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0=
|
||||||
|
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
|
||||||
|
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
|
||||||
|
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
|
||||||
|
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||||
|
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||||
|
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||||
|
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||||
|
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
|
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||||
|
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||||
|
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
||||||
|
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package configserver
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
)
|
||||||
|
|
||||||
|
// api_configure.go — изменение конфигурации сервиса (compose/env) и её версий.
|
||||||
|
|
||||||
|
// SetCompose устанавливает текущий docker-compose и отправляет его агенту.
|
||||||
|
func (a *API) SetCompose(ctx context.Context, appID int, content string) (*ConfigAck, error) {
|
||||||
|
ctx, cancel := a.withTimeout(ctx)
|
||||||
|
defer cancel()
|
||||||
|
out := &ConfigAck{}
|
||||||
|
body := map[string]string{"content": content}
|
||||||
|
if err := a.Sess.postJSON(ctx, "/api/compose/"+strconv.Itoa(appID), body, out); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetEnv устанавливает текущий env-файл и отправляет его агенту.
|
||||||
|
func (a *API) SetEnv(ctx context.Context, appID int, filename, content string) (*ConfigAck, error) {
|
||||||
|
ctx, cancel := a.withTimeout(ctx)
|
||||||
|
defer cancel()
|
||||||
|
out := &ConfigAck{}
|
||||||
|
body := map[string]string{"content": content}
|
||||||
|
path := "/api/env/" + strconv.Itoa(appID) + "/" + url.PathEscape(filename)
|
||||||
|
if err := a.Sess.postJSON(ctx, path, body, out); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RestoreCompose восстанавливает версию compose (создаёт новую текущую версию).
|
||||||
|
func (a *API) RestoreCompose(ctx context.Context, versionID int) (*StatusAck, error) {
|
||||||
|
ctx, cancel := a.withTimeout(ctx)
|
||||||
|
defer cancel()
|
||||||
|
out := &StatusAck{}
|
||||||
|
if err := a.Sess.postJSON(ctx, "/api/restore_compose/"+strconv.Itoa(versionID), nil, out); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RestoreEnvVersion восстанавливает версию env-файла.
|
||||||
|
func (a *API) RestoreEnvVersion(ctx context.Context, versionID int) (*StatusAck, error) {
|
||||||
|
ctx, cancel := a.withTimeout(ctx)
|
||||||
|
defer cancel()
|
||||||
|
out := &StatusAck{}
|
||||||
|
if err := a.Sess.postJSON(ctx, "/api/restore_env_version/"+strconv.Itoa(versionID), nil, out); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateConfig повторно отправляет агенту текущую конфигурацию из БД.
|
||||||
|
func (a *API) UpdateConfig(ctx context.Context, appID int) (*TaskAck, error) {
|
||||||
|
ctx, cancel := a.withTimeout(ctx)
|
||||||
|
defer cancel()
|
||||||
|
out := &TaskAck{}
|
||||||
|
if err := a.Sess.postJSON(ctx, "/api/update_config/"+strconv.Itoa(appID), nil, out); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package configserver
|
||||||
|
|
||||||
|
import "context"
|
||||||
|
|
||||||
|
// api_maintain.go — обслуживание: очистка старых версий.
|
||||||
|
|
||||||
|
// CleanupOldVersions удаляет версии compose/env и логи миграций старше days дней
|
||||||
|
// (текущие версии не трогаются).
|
||||||
|
func (a *API) CleanupOldVersions(ctx context.Context, days int) (*CleanupAck, error) {
|
||||||
|
ctx, cancel := a.withTimeout(ctx)
|
||||||
|
defer cancel()
|
||||||
|
body := map[string]int{"days": days}
|
||||||
|
out := &CleanupAck{}
|
||||||
|
if err := a.Sess.postJSON(ctx, "/api/cleanup_old_versions", body, out); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,289 @@
|
|||||||
|
package configserver
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// api_observe.go — read-only операции (наблюдение и диагностика).
|
||||||
|
|
||||||
|
// WhoAmI — ответ GET /api/whoami.
|
||||||
|
type WhoAmI struct {
|
||||||
|
Authenticated bool `json:"authenticated"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Capabilities — результат capability-probe: версия API и доступные группы
|
||||||
|
// JSON-эндпоинтов v1.1.0. Нужен, чтобы инструменты не «тихо» ломались на
|
||||||
|
// старых стендах, а возвращали внятное сообщение.
|
||||||
|
type Capabilities struct {
|
||||||
|
Version string `json:"version"`
|
||||||
|
Features map[string]bool `json:"features"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Health проверяет живость сервера (GET /health, без авторизации).
|
||||||
|
func (a *API) Health(ctx context.Context) (int, string, error) {
|
||||||
|
ctx, cancel := a.withTimeout(ctx)
|
||||||
|
defer cancel()
|
||||||
|
resp, err := a.Sess.getPublic(ctx, "/health")
|
||||||
|
if err != nil {
|
||||||
|
return 0, "", err
|
||||||
|
}
|
||||||
|
return resp.status, strings.TrimSpace(string(resp.body)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// WhoAmI проверяет текущую авторизацию.
|
||||||
|
func (a *API) WhoAmI(ctx context.Context) (*WhoAmI, error) {
|
||||||
|
ctx, cancel := a.withTimeout(ctx)
|
||||||
|
defer cancel()
|
||||||
|
out := &WhoAmI{}
|
||||||
|
if err := a.Sess.getJSON(ctx, "/api/whoami", nil, out); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Probe определяет версию API. Ключевой признак v1.1.0 — публичный JSON
|
||||||
|
// `/api/whoami` (в <=1.0.20 его нет, middleware отвечает 401).
|
||||||
|
func (a *API) Probe(ctx context.Context) (*Capabilities, error) {
|
||||||
|
ctx, cancel := a.withTimeout(ctx)
|
||||||
|
defer cancel()
|
||||||
|
resp, err := a.Sess.getPublic(ctx, "/api/whoami")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
caps := &Capabilities{Version: "legacy (<1.1.0)", Features: map[string]bool{}}
|
||||||
|
if resp.status == 200 && strings.Contains(resp.ctype, "json") {
|
||||||
|
caps.Version = "1.1.0"
|
||||||
|
for _, f := range []string{
|
||||||
|
"api_login", "api_whoami", "application_detail",
|
||||||
|
"set_compose", "set_env", "tasks_filter", "task_get", "update_front",
|
||||||
|
} {
|
||||||
|
caps.Features[f] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return caps, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListApplications возвращает список сервисов.
|
||||||
|
func (a *API) ListApplications(ctx context.Context, includePresence bool) ([]Application, error) {
|
||||||
|
ctx, cancel := a.withTimeout(ctx)
|
||||||
|
defer cancel()
|
||||||
|
q := url.Values{}
|
||||||
|
if includePresence {
|
||||||
|
q.Set("include_presence", "true")
|
||||||
|
}
|
||||||
|
var out []Application
|
||||||
|
if err := a.Sess.getJSON(ctx, "/api/applications", q, &out); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetApplication возвращает детализацию сервиса.
|
||||||
|
func (a *API) GetApplication(ctx context.Context, id int) (*ApplicationDetail, error) {
|
||||||
|
ctx, cancel := a.withTimeout(ctx)
|
||||||
|
defer cancel()
|
||||||
|
out := &ApplicationDetail{}
|
||||||
|
if err := a.Sess.getJSON(ctx, "/api/application/"+strconv.Itoa(id), nil, out); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetConfig возвращает текущие compose/env сервиса (эндпоинт агента, публичный).
|
||||||
|
func (a *API) GetConfig(ctx context.Context, id int) (*RawConfig, error) {
|
||||||
|
ctx, cancel := a.withTimeout(ctx)
|
||||||
|
defer cancel()
|
||||||
|
out := &RawConfig{}
|
||||||
|
if err := a.Sess.getJSON(ctx, "/api/get_config/"+strconv.Itoa(id), nil, out); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnvFiles возвращает имена текущих env-файлов сервиса.
|
||||||
|
func (a *API) EnvFiles(ctx context.Context, id int) ([]string, error) {
|
||||||
|
ctx, cancel := a.withTimeout(ctx)
|
||||||
|
defer cancel()
|
||||||
|
var out []string
|
||||||
|
if err := a.Sess.getJSON(ctx, "/api/env_files/"+strconv.Itoa(id), nil, &out); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ComposeVersion возвращает конкретную версию compose.
|
||||||
|
func (a *API) ComposeVersion(ctx context.Context, versionID int) (*ComposeVersion, error) {
|
||||||
|
ctx, cancel := a.withTimeout(ctx)
|
||||||
|
defer cancel()
|
||||||
|
out := &ComposeVersion{}
|
||||||
|
if err := a.Sess.getJSON(ctx, "/api/compose_version/"+strconv.Itoa(versionID), nil, out); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnvVersion возвращает конкретную версию env-файла.
|
||||||
|
func (a *API) EnvVersion(ctx context.Context, versionID int) (*EnvVersion, error) {
|
||||||
|
ctx, cancel := a.withTimeout(ctx)
|
||||||
|
defer cancel()
|
||||||
|
out := &EnvVersion{}
|
||||||
|
if err := a.Sess.getJSON(ctx, "/api/env_version/"+strconv.Itoa(versionID), nil, out); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnvVersions возвращает все версии конкретного env-файла.
|
||||||
|
func (a *API) EnvVersions(ctx context.Context, appID int, filename string) ([]EnvVersion, error) {
|
||||||
|
ctx, cancel := a.withTimeout(ctx)
|
||||||
|
defer cancel()
|
||||||
|
path := "/api/env_versions/" + strconv.Itoa(appID) + "/" + url.PathEscape(filename)
|
||||||
|
var out []EnvVersion
|
||||||
|
if err := a.Sess.getJSON(ctx, path, nil, &out); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// TaskFilter — фильтры списка задач.
|
||||||
|
type TaskFilter struct {
|
||||||
|
HostIP string
|
||||||
|
Status string
|
||||||
|
TaskType string
|
||||||
|
Limit int
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListTasks возвращает задачи с фильтрами (GET /api/tasks).
|
||||||
|
func (a *API) ListTasks(ctx context.Context, f TaskFilter) ([]Task, error) {
|
||||||
|
ctx, cancel := a.withTimeout(ctx)
|
||||||
|
defer cancel()
|
||||||
|
q := url.Values{}
|
||||||
|
if f.HostIP != "" {
|
||||||
|
q.Set("host_ip", f.HostIP)
|
||||||
|
}
|
||||||
|
if f.Status != "" {
|
||||||
|
q.Set("status", f.Status)
|
||||||
|
}
|
||||||
|
if f.TaskType != "" {
|
||||||
|
q.Set("task_type", f.TaskType)
|
||||||
|
}
|
||||||
|
if f.Limit > 0 {
|
||||||
|
q.Set("limit", strconv.Itoa(f.Limit))
|
||||||
|
}
|
||||||
|
var out []Task
|
||||||
|
if err := a.Sess.getJSON(ctx, "/api/tasks", q, &out); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTask возвращает задачу по task_id.
|
||||||
|
func (a *API) GetTask(ctx context.Context, taskID string) (*Task, error) {
|
||||||
|
ctx, cancel := a.withTimeout(ctx)
|
||||||
|
defer cancel()
|
||||||
|
out := &Task{}
|
||||||
|
if err := a.Sess.getJSON(ctx, "/api/task/"+url.PathEscape(taskID), nil, out); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// WaitTask опрашивает задачу до завершения (completed/failed) или до истечения
|
||||||
|
// task_poll_max_sec. Возвращает последнее известное состояние задачи; признак
|
||||||
|
// незавершённости — Status == "pending"/"in_progress". Loop агента
|
||||||
|
// последовательный, поэтому ожидание строго ограничено конфигом.
|
||||||
|
func (a *API) WaitTask(ctx context.Context, taskID string) (*Task, error) {
|
||||||
|
deadline := time.Now().Add(time.Duration(a.Srv.TaskPollMaxSec) * time.Second)
|
||||||
|
interval := time.Duration(a.Srv.TaskPollIntervalSec) * time.Second
|
||||||
|
if interval <= 0 {
|
||||||
|
interval = time.Second
|
||||||
|
}
|
||||||
|
for {
|
||||||
|
task, err := a.GetTask(ctx, taskID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if task.Status == "completed" || task.Status == "failed" {
|
||||||
|
return task, nil
|
||||||
|
}
|
||||||
|
if !time.Now().Before(deadline) {
|
||||||
|
return task, nil
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return task, nil
|
||||||
|
case <-time.After(interval):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeploymentFiles возвращает schema/manifest/prebuild_vars/ip_overrides.
|
||||||
|
func (a *API) DeploymentFiles(ctx context.Context) (*DeploymentFiles, error) {
|
||||||
|
ctx, cancel := a.withTimeout(ctx)
|
||||||
|
defer cancel()
|
||||||
|
out := &DeploymentFiles{}
|
||||||
|
if err := a.Sess.getJSON(ctx, "/api/deployment/files", nil, out); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// IPMatch возвращает диагностику сопоставления IP схемы и агентов.
|
||||||
|
func (a *API) IPMatch(ctx context.Context) (*IPMatch, error) {
|
||||||
|
ctx, cancel := a.withTimeout(ctx)
|
||||||
|
defer cancel()
|
||||||
|
out := &IPMatch{}
|
||||||
|
if err := a.Sess.getJSON(ctx, "/api/deployment/ip_match", nil, out); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReleaseJob возвращает статус фоновой загрузки релиза.
|
||||||
|
func (a *API) ReleaseJob(ctx context.Context) (*ReleaseJob, error) {
|
||||||
|
ctx, cancel := a.withTimeout(ctx)
|
||||||
|
defer cancel()
|
||||||
|
out := &ReleaseJob{}
|
||||||
|
if err := a.Sess.getJSON(ctx, "/api/deployment/release_job", nil, &out); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeploymentTasks возвращает последние release-задачи (sync/start).
|
||||||
|
func (a *API) DeploymentTasks(ctx context.Context) ([]Task, error) {
|
||||||
|
ctx, cancel := a.withTimeout(ctx)
|
||||||
|
defer cancel()
|
||||||
|
var out []Task
|
||||||
|
if err := a.Sess.getJSON(ctx, "/api/deployment/tasks", nil, &out); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stats возвращает счётчики версий.
|
||||||
|
func (a *API) Stats(ctx context.Context) (*Stats, error) {
|
||||||
|
ctx, cancel := a.withTimeout(ctx)
|
||||||
|
defer cancel()
|
||||||
|
out := &Stats{}
|
||||||
|
if err := a.Sess.getJSON(ctx, "/api/stats", nil, out); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServiceMap возвращает карту серверов и сервисов.
|
||||||
|
func (a *API) ServiceMap(ctx context.Context) (*ServiceMap, error) {
|
||||||
|
ctx, cancel := a.withTimeout(ctx)
|
||||||
|
defer cancel()
|
||||||
|
out := &ServiceMap{}
|
||||||
|
if err := a.Sess.getJSON(ctx, "/api/service_map", nil, out); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
package configserver
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// api_operate.go — жизненный цикл сервиса и загрузка фронта.
|
||||||
|
|
||||||
|
// Deploy поднимает сервис (docker-compose up -d).
|
||||||
|
func (a *API) Deploy(ctx context.Context, appID int) (*TaskAck, error) {
|
||||||
|
return a.lifecycle(ctx, "deploy", appID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restart перезапускает сервис (down + up -d).
|
||||||
|
func (a *API) Restart(ctx context.Context, appID int) (*TaskAck, error) {
|
||||||
|
return a.lifecycle(ctx, "restart", appID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Down останавливает сервис (docker-compose down).
|
||||||
|
func (a *API) Down(ctx context.Context, appID int) (*TaskAck, error) {
|
||||||
|
return a.lifecycle(ctx, "down", appID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Migrate запускает миграцию (docker-compose run migration).
|
||||||
|
func (a *API) Migrate(ctx context.Context, appID int) (*TaskAck, error) {
|
||||||
|
return a.lifecycle(ctx, "migrate", appID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// lifecycle — общий вызов POST /api/<action>/<app_id>, возвращающий task_id.
|
||||||
|
func (a *API) lifecycle(ctx context.Context, action string, appID int) (*TaskAck, error) {
|
||||||
|
ctx, cancel := a.withTimeout(ctx)
|
||||||
|
defer cancel()
|
||||||
|
out := &TaskAck{}
|
||||||
|
path := "/api/" + action + "/" + strconv.Itoa(appID)
|
||||||
|
if err := a.Sess.postJSON(ctx, path, nil, out); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateFront загружает локальную сборку фронта и создаёт задачу её доставки
|
||||||
|
// агенту. Каталог обязан лежать внутри front_roots (jail, fail-closed).
|
||||||
|
func (a *API) UpdateFront(ctx context.Context, appID int, buildDir string) (*FrontAck, error) {
|
||||||
|
files, err := a.collectFrontFiles(buildDir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
ctx, cancel := a.withUploadTimeout(ctx)
|
||||||
|
defer cancel()
|
||||||
|
raw, err := a.Sess.postMultipart(ctx, "/api/update_front/"+strconv.Itoa(appID), files)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := &FrontAck{}
|
||||||
|
if err := json.Unmarshal(raw, out); err != nil {
|
||||||
|
return nil, fmt.Errorf("разбор ответа update_front: %w", err)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// collectFrontFiles собирает файлы сборки, проверяя containment и лимиты.
|
||||||
|
func (a *API) collectFrontFiles(buildDir string) ([]UploadFile, error) {
|
||||||
|
if len(a.Srv.FrontRoots) == 0 {
|
||||||
|
return nil, ErrNoFrontRoots
|
||||||
|
}
|
||||||
|
root, err := resolveInsideRoots(buildDir, a.Srv.FrontRoots)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
files []UploadFile
|
||||||
|
total int64
|
||||||
|
)
|
||||||
|
err = filepath.WalkDir(root, func(path string, d fs.DirEntry, walkErr error) error {
|
||||||
|
if walkErr != nil {
|
||||||
|
return walkErr
|
||||||
|
}
|
||||||
|
if d.IsDir() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// Симлинки и спецфайлы пропускаем: загружаем только обычные файлы.
|
||||||
|
if !d.Type().IsRegular() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
info, err := d.Info()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
total += info.Size()
|
||||||
|
if total > a.Srv.MaxUploadBytes {
|
||||||
|
return validationErrorf("размер сборки превышает лимит %d байт", a.Srv.MaxUploadBytes)
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rel, err := filepath.Rel(root, path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
files = append(files, UploadFile{Name: filepath.ToSlash(rel), Data: data})
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("чтение сборки %s: %w", buildDir, err)
|
||||||
|
}
|
||||||
|
if len(files) == 0 {
|
||||||
|
return nil, validationErrorf("в каталоге %s нет файлов", buildDir)
|
||||||
|
}
|
||||||
|
return files, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveInsideRoots приводит путь к абсолютному и проверяет, что после
|
||||||
|
// разрешения симлинков он лежит внутри одного из разрешённых корней.
|
||||||
|
func resolveInsideRoots(target string, roots []string) (string, error) {
|
||||||
|
abs, err := filepath.Abs(target)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("некорректный путь %q: %w", target, err)
|
||||||
|
}
|
||||||
|
resolved, err := filepath.EvalSymlinks(abs)
|
||||||
|
if err != nil {
|
||||||
|
return "", validationErrorf("путь %q недоступен: %v", target, err)
|
||||||
|
}
|
||||||
|
resolved = filepath.Clean(resolved)
|
||||||
|
|
||||||
|
for _, r := range roots {
|
||||||
|
rootAbs, err := filepath.Abs(r)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Симлинки в самом корне не должны «съесть» проверку — разрешаем, если можем.
|
||||||
|
if rootResolved, err := filepath.EvalSymlinks(rootAbs); err == nil {
|
||||||
|
rootAbs = rootResolved
|
||||||
|
}
|
||||||
|
rootAbs = filepath.Clean(rootAbs)
|
||||||
|
if resolved == rootAbs || strings.HasPrefix(resolved, rootAbs+string(os.PathSeparator)) {
|
||||||
|
return resolved, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", validationErrorf("путь %q вне разрешённых front_roots", target)
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
package configserver
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestResolveInsideRoots(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
sub := filepath.Join(root, "build")
|
||||||
|
if err := os.MkdirAll(sub, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(sub, "index.html"), []byte("x"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := resolveInsideRoots(sub, []string{root}); err != nil {
|
||||||
|
t.Fatalf("каталог внутри root должен быть разрешён: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
outside := t.TempDir()
|
||||||
|
if _, err := resolveInsideRoots(outside, []string{root}); err == nil {
|
||||||
|
t.Fatal("каталог вне root должен быть отклонён")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveInsideRootsSymlinkEscape(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
outside := t.TempDir()
|
||||||
|
if err := os.WriteFile(filepath.Join(outside, "secret"), []byte("x"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
link := filepath.Join(root, "escape")
|
||||||
|
if err := os.Symlink(outside, link); err != nil {
|
||||||
|
t.Skipf("симлинки недоступны: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := resolveInsideRoots(link, []string{root}); err == nil {
|
||||||
|
t.Fatal("symlink-побег из root должен быть отклонён")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCollectFrontFilesNoRoots(t *testing.T) {
|
||||||
|
api := &API{Srv: Server{}}
|
||||||
|
_, err := api.collectFrontFiles("/tmp")
|
||||||
|
if !errors.Is(err, ErrNoFrontRoots) {
|
||||||
|
t.Fatalf("ожидалась ErrNoFrontRoots, получено: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCollectFrontFiles(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
build := filepath.Join(root, "dist")
|
||||||
|
if err := os.MkdirAll(filepath.Join(build, "assets"), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(build, "index.html"), []byte("<html/>"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(build, "assets", "app.js"), []byte("js"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
api := &API{Srv: Server{FrontRoots: []string{root}, MaxUploadBytes: DefaultMaxUploadBytes}}
|
||||||
|
files, err := api.collectFrontFiles(build)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("collectFrontFiles: %v", err)
|
||||||
|
}
|
||||||
|
if len(files) != 2 {
|
||||||
|
t.Fatalf("файлов = %d, ожидалось 2", len(files))
|
||||||
|
}
|
||||||
|
names := map[string]bool{}
|
||||||
|
for _, f := range files {
|
||||||
|
names[f.Name] = true
|
||||||
|
}
|
||||||
|
if !names["index.html"] || !names["assets/app.js"] {
|
||||||
|
t.Fatalf("неожиданные относительные пути: %+v", names)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
package configserver
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
)
|
||||||
|
|
||||||
|
// api_release.go — страница «Развёртывание»: файлы схемы, seed/fill, релиз.
|
||||||
|
|
||||||
|
// DeploymentUpdate — частичное обновление файлов развёртывания. nil-поля не
|
||||||
|
// отправляются (сервер не перезаписывает их пустыми значениями).
|
||||||
|
type DeploymentUpdate struct {
|
||||||
|
Schema *string
|
||||||
|
Manifest *string
|
||||||
|
PrebuildVars *string
|
||||||
|
IPOverrides *string
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveDeploymentFiles сохраняет schema/manifest/prebuild_vars/ip_overrides.
|
||||||
|
func (a *API) SaveDeploymentFiles(ctx context.Context, upd DeploymentUpdate) (*StatusAck, error) {
|
||||||
|
ctx, cancel := a.withTimeout(ctx)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
body := map[string]string{}
|
||||||
|
if upd.Schema != nil {
|
||||||
|
body["schema"] = *upd.Schema
|
||||||
|
}
|
||||||
|
if upd.Manifest != nil {
|
||||||
|
body["manifest"] = *upd.Manifest
|
||||||
|
}
|
||||||
|
if upd.PrebuildVars != nil {
|
||||||
|
body["prebuild_vars"] = *upd.PrebuildVars
|
||||||
|
}
|
||||||
|
if upd.IPOverrides != nil {
|
||||||
|
body["ip_overrides"] = *upd.IPOverrides
|
||||||
|
}
|
||||||
|
|
||||||
|
out := &StatusAck{}
|
||||||
|
if err := a.Sess.postJSON(ctx, "/api/deployment/files", body, out); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SeedHosts создаёт хосты из schema.json.
|
||||||
|
func (a *API) SeedHosts(ctx context.Context) (*SeedHostsAck, error) {
|
||||||
|
ctx, cancel := a.withTimeout(ctx)
|
||||||
|
defer cancel()
|
||||||
|
out := &SeedHostsAck{}
|
||||||
|
if err := a.Sess.postJSON(ctx, "/api/deployment/seed_hosts", nil, out); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FillVars заполняет prebuild_vars из схемы (ответ — произвольный JSON).
|
||||||
|
func (a *API) FillVars(ctx context.Context) (json.RawMessage, error) {
|
||||||
|
ctx, cancel := a.withTimeout(ctx)
|
||||||
|
defer cancel()
|
||||||
|
var out json.RawMessage
|
||||||
|
if err := a.Sess.postJSON(ctx, "/api/deployment/fill_vars", nil, &out); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DownloadRelease запускает загрузку релиза. При load_images=true сервер
|
||||||
|
// отвечает сразу (фоновая задача), прогресс читается через ReleaseJob.
|
||||||
|
func (a *API) DownloadRelease(ctx context.Context, token string, loadImages bool) (*DownloadReleaseAck, error) {
|
||||||
|
if token == "" {
|
||||||
|
token = a.Srv.GitlabToken
|
||||||
|
}
|
||||||
|
body := map[string]any{"load_images": loadImages}
|
||||||
|
if token != "" {
|
||||||
|
body["gitlab_token"] = token
|
||||||
|
}
|
||||||
|
|
||||||
|
// Синхронная выгрузка (load_images=false) может быть долгой.
|
||||||
|
ctx, cancel := a.withUploadTimeout(ctx)
|
||||||
|
defer cancel()
|
||||||
|
out := &DownloadReleaseAck{}
|
||||||
|
if err := a.Sess.postJSON(ctx, "/api/deployment/download_release", body, out); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartServices создаёт задачи запуска сервисов для хостов из схемы.
|
||||||
|
func (a *API) StartServices(ctx context.Context) (*StartServicesAck, error) {
|
||||||
|
ctx, cancel := a.withTimeout(ctx)
|
||||||
|
defer cancel()
|
||||||
|
out := &StartServicesAck{}
|
||||||
|
if err := a.Sess.postJSON(ctx, "/api/deployment/start_services", nil, out); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,391 @@
|
|||||||
|
// Package configserver — доменный слой MCP-модуля omnichannel-mcp.
|
||||||
|
//
|
||||||
|
// Здесь нет зависимостей от MCP: только разбор конфига, авторизованные
|
||||||
|
// HTTP-сессии к API config_server и типизированные вызовы его эндпоинтов.
|
||||||
|
// MCP-слой (internal/tools) — тонкая обёртка, которая парсит аргументы,
|
||||||
|
// дёргает этот пакет и форматирует результат.
|
||||||
|
package configserver
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.totmin.ru/en2zmax/forge-toolkit"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Значения по умолчанию для лимитов сервера. Все они задаются в config.json,
|
||||||
|
// но модуль должен работать и с минимальным конфигом (только base_url).
|
||||||
|
const (
|
||||||
|
DefaultTimeoutSec = 30
|
||||||
|
DefaultUploadTimeoutSec = 300
|
||||||
|
DefaultTaskPollMaxSec = 120
|
||||||
|
DefaultTaskPollIntervalSec = 2
|
||||||
|
DefaultMaxOutputBytes = 100_000
|
||||||
|
DefaultMaxUploadBytes int64 = 512 << 20 // 512 MiB
|
||||||
|
DefaultMaxConcurrentMutations = 1
|
||||||
|
)
|
||||||
|
|
||||||
|
// Server — одно подключение к config_server (стенд/контур).
|
||||||
|
type Server struct {
|
||||||
|
Alias string `json:"alias"`
|
||||||
|
// BaseURL — адрес API, например http://10.101.60.3:5005.
|
||||||
|
BaseURL string `json:"base_url"`
|
||||||
|
|
||||||
|
// Username/Password — учётная запись с правом записи (используется
|
||||||
|
// изменяющими инструментами).
|
||||||
|
Username string `json:"username"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
|
||||||
|
// ReadonlyUsername/ReadonlyPassword — необязательная учётная запись только
|
||||||
|
// для чтения. Если задана, read-инструменты используют её (least-privilege
|
||||||
|
// на нашей стороне; см. ARCHITECTURE.md §9.5).
|
||||||
|
ReadonlyUsername string `json:"readonly_username"`
|
||||||
|
ReadonlyPassword string `json:"readonly_password"`
|
||||||
|
|
||||||
|
// GitlabToken — токен для /api/deployment/download_release. Секрет: держим
|
||||||
|
// в env/локальном конфиге, никогда не логируем.
|
||||||
|
GitlabToken string `json:"gitlab_token"`
|
||||||
|
|
||||||
|
// InsecureSkipVerify отключает проверку TLS-сертификата (для стендов с
|
||||||
|
// самоподписанными сертификатами).
|
||||||
|
InsecureSkipVerify bool `json:"insecure_skip_verify"`
|
||||||
|
|
||||||
|
TimeoutSec int `json:"timeout_sec"`
|
||||||
|
UploadTimeoutSec int `json:"upload_timeout_sec"`
|
||||||
|
TaskPollMaxSec int `json:"task_poll_max_sec"`
|
||||||
|
TaskPollIntervalSec int `json:"task_poll_interval_sec"`
|
||||||
|
MaxOutputBytes int `json:"max_output_bytes"`
|
||||||
|
MaxUploadBytes int64 `json:"max_upload_bytes"`
|
||||||
|
MaxConcurrentMutations int `json:"max_concurrent_mutations"`
|
||||||
|
|
||||||
|
// FrontRoots — разрешённые каталоги локальных сборок фронта для
|
||||||
|
// update_front (jail). Пусто — загрузка фронта запрещена (fail-closed).
|
||||||
|
FrontRoots []string `json:"front_roots"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Config — корневой конфиг модуля (config.json или per-agent omnichannel-mcp.json).
|
||||||
|
type Config struct {
|
||||||
|
Servers []Server `json:"servers"`
|
||||||
|
Default string `json:"default"`
|
||||||
|
ReadOnly bool `json:"read_only"`
|
||||||
|
|
||||||
|
// AllowHosts — необязательный allowlist хостов. Если задан, base_url любого
|
||||||
|
// сервера обязан быть на одном из этих хостов (защита от опечаток/SSRF).
|
||||||
|
// Пусто — доверяем хостам из самих серверов.
|
||||||
|
AllowHosts []string `json:"allow_hosts"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// rawConfig — форма для строгого разбора: позволяет отличить «read_only не
|
||||||
|
// задан» (тогда безопасный дефолт true) от явного false.
|
||||||
|
type rawConfig struct {
|
||||||
|
Servers []Server `json:"servers"`
|
||||||
|
Default string `json:"default"`
|
||||||
|
ReadOnly *bool `json:"read_only"`
|
||||||
|
AllowHosts []string `json:"allow_hosts"`
|
||||||
|
|
||||||
|
// Single-server shorthand: если servers пуст, а base_url задан — считаем это
|
||||||
|
// одним сервером с алиасом "default".
|
||||||
|
BaseURL string `json:"base_url"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var envVarRe = regexp.MustCompile(`\$\{([A-Za-z_][A-Za-z0-9_]*)\}`)
|
||||||
|
|
||||||
|
// ParseConfig разбирает конфиг: раскрывает ${VAR} из окружения, применяет
|
||||||
|
// дефолты и валидирует. Fail-closed: незаполненная переменная, битый адрес или
|
||||||
|
// отсутствие серверов — ошибка, а не «подозрительный дефолт».
|
||||||
|
func ParseConfig(data []byte) (*Config, error) {
|
||||||
|
if unresolved := unresolvedVars(data); len(unresolved) > 0 {
|
||||||
|
return nil, fmt.Errorf("не заданы переменные окружения: %s", strings.Join(unresolved, ", "))
|
||||||
|
}
|
||||||
|
return decodeConfig(toolkit.Expand(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
// decodeConfig разбирает уже раскрытый JSON (без ${VAR}).
|
||||||
|
func decodeConfig(data []byte) (*Config, error) {
|
||||||
|
var raw rawConfig
|
||||||
|
dec := json.NewDecoder(strings.NewReader(string(data)))
|
||||||
|
dec.DisallowUnknownFields()
|
||||||
|
if err := dec.Decode(&raw); err != nil {
|
||||||
|
return nil, fmt.Errorf("разбор config.json: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := &Config{
|
||||||
|
Servers: raw.Servers,
|
||||||
|
Default: strings.TrimSpace(raw.Default),
|
||||||
|
ReadOnly: true,
|
||||||
|
AllowHosts: raw.AllowHosts,
|
||||||
|
}
|
||||||
|
if raw.ReadOnly != nil {
|
||||||
|
cfg.ReadOnly = *raw.ReadOnly
|
||||||
|
}
|
||||||
|
if len(cfg.Servers) == 0 && strings.TrimSpace(raw.BaseURL) != "" {
|
||||||
|
cfg.Servers = []Server{{Alias: "default", BaseURL: raw.BaseURL, Username: raw.Username, Password: raw.Password}}
|
||||||
|
}
|
||||||
|
if err := cfg.normalize(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// LocalConfigSuffix — суффикс локального файла с секретами (gitignored).
|
||||||
|
const LocalConfigSuffix = ".local"
|
||||||
|
|
||||||
|
// LoadFile загружает конфиг из основного файла, накладывая поверх соседний
|
||||||
|
// <name>.local.json (реальные секреты; в репозитории — только шаблон).
|
||||||
|
func LoadFile(path string) (*Config, error) {
|
||||||
|
mainData, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if unresolved := unresolvedVars(mainData); len(unresolved) > 0 {
|
||||||
|
return nil, fmt.Errorf("не заданы переменные окружения: %s", strings.Join(unresolved, ", "))
|
||||||
|
}
|
||||||
|
|
||||||
|
expanded := toolkit.Expand(mainData)
|
||||||
|
if localPath := localConfigPath(path); localPath != "" {
|
||||||
|
if localData, err := os.ReadFile(localPath); err == nil {
|
||||||
|
if unresolved := unresolvedVars(localData); len(unresolved) > 0 {
|
||||||
|
return nil, fmt.Errorf("не заданы переменные окружения (в %s): %s", localPath, strings.Join(unresolved, ", "))
|
||||||
|
}
|
||||||
|
if expanded, err = mergeConfigJSON(expanded, toolkit.Expand(localData)); err != nil {
|
||||||
|
return nil, fmt.Errorf("слияние %s: %w", localPath, err)
|
||||||
|
}
|
||||||
|
} else if !os.IsNotExist(err) {
|
||||||
|
return nil, fmt.Errorf("чтение %s: %w", localPath, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return decodeConfig(expanded)
|
||||||
|
}
|
||||||
|
|
||||||
|
// localConfigPath возвращает путь <dir>/<name>.local.json для основного файла.
|
||||||
|
func localConfigPath(path string) string {
|
||||||
|
if strings.HasSuffix(path, ".json") {
|
||||||
|
return strings.TrimSuffix(path, ".json") + LocalConfigSuffix + ".json"
|
||||||
|
}
|
||||||
|
return path + LocalConfigSuffix
|
||||||
|
}
|
||||||
|
|
||||||
|
// mergeConfigJSON накладывает over поверх base: объекты сливаются рекурсивно,
|
||||||
|
// массивы servers — по alias (локальный сервер дополняет/перезаписывает
|
||||||
|
// одноимённый), прочие значения over перезаписывают base.
|
||||||
|
func mergeConfigJSON(base, over []byte) ([]byte, error) {
|
||||||
|
var baseMap, overMap map[string]any
|
||||||
|
if err := json.Unmarshal(base, &baseMap); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(over, &overMap); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
merged := mergeMaps(baseMap, overMap)
|
||||||
|
return json.Marshal(merged)
|
||||||
|
}
|
||||||
|
|
||||||
|
// mergeMaps рекурсивно сливает карты; список servers обрабатывается отдельно.
|
||||||
|
func mergeMaps(base, over map[string]any) map[string]any {
|
||||||
|
out := make(map[string]any, len(base)+len(over))
|
||||||
|
for k, v := range base {
|
||||||
|
out[k] = v
|
||||||
|
}
|
||||||
|
for k, v := range over {
|
||||||
|
if k == "servers" {
|
||||||
|
out[k] = mergeServers(out[k], v)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if baseChild, ok := out[k].(map[string]any); ok {
|
||||||
|
if overChild, ok := v.(map[string]any); ok {
|
||||||
|
out[k] = mergeMaps(baseChild, overChild)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out[k] = v
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// mergeServers сливает массивы серверов по полю alias.
|
||||||
|
func mergeServers(base, over any) []any {
|
||||||
|
baseList, _ := base.([]any)
|
||||||
|
overList, _ := over.([]any)
|
||||||
|
|
||||||
|
index := map[string]int{}
|
||||||
|
var result []any
|
||||||
|
for _, item := range baseList {
|
||||||
|
m, _ := item.(map[string]any)
|
||||||
|
alias, _ := m["alias"].(string)
|
||||||
|
index[alias] = len(result)
|
||||||
|
result = append(result, m)
|
||||||
|
}
|
||||||
|
for _, item := range overList {
|
||||||
|
m, _ := item.(map[string]any)
|
||||||
|
alias, _ := m["alias"].(string)
|
||||||
|
if pos, ok := index[alias]; ok {
|
||||||
|
if baseServer, ok := result[pos].(map[string]any); ok {
|
||||||
|
result[pos] = mergeMaps(baseServer, m)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
index[alias] = len(result)
|
||||||
|
result = append(result, m)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// unresolvedVars возвращает имена переменных ${VAR}, которых нет в окружении.
|
||||||
|
func unresolvedVars(data []byte) []string {
|
||||||
|
seen := map[string]bool{}
|
||||||
|
var out []string
|
||||||
|
for _, m := range envVarRe.FindAllSubmatch(data, -1) {
|
||||||
|
name := string(m[1])
|
||||||
|
if seen[name] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[name] = true
|
||||||
|
if os.Getenv(name) == "" {
|
||||||
|
out = append(out, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// normalize применяет дефолты и валидирует конфиг.
|
||||||
|
func (c *Config) normalize() error {
|
||||||
|
if len(c.Servers) == 0 {
|
||||||
|
return fmt.Errorf("не задано ни одного сервера (servers или base_url)")
|
||||||
|
}
|
||||||
|
|
||||||
|
aliases := map[string]bool{}
|
||||||
|
for i := range c.Servers {
|
||||||
|
s := &c.Servers[i]
|
||||||
|
if strings.TrimSpace(s.Alias) == "" {
|
||||||
|
return fmt.Errorf("servers[%d]: не задан alias", i)
|
||||||
|
}
|
||||||
|
if aliases[s.Alias] {
|
||||||
|
return fmt.Errorf("дублирующийся alias %q", s.Alias)
|
||||||
|
}
|
||||||
|
aliases[s.Alias] = true
|
||||||
|
if err := c.validateServer(s); err != nil {
|
||||||
|
return fmt.Errorf("сервер %q: %w", s.Alias, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// default: явный или первый сервер.
|
||||||
|
if c.Default == "" {
|
||||||
|
c.Default = c.Servers[0].Alias
|
||||||
|
}
|
||||||
|
if !aliases[c.Default] {
|
||||||
|
return fmt.Errorf("default %q не найден среди серверов (%s)", c.Default, strings.Join(c.ServerNames(), ", "))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateServer проверяет адрес, allowlist и заполняет лимиты дефолтами.
|
||||||
|
func (c *Config) validateServer(s *Server) error {
|
||||||
|
u, err := url.Parse(strings.TrimSpace(s.BaseURL))
|
||||||
|
if err != nil || u.Scheme == "" || u.Host == "" {
|
||||||
|
return fmt.Errorf("некорректный base_url %q", s.BaseURL)
|
||||||
|
}
|
||||||
|
if u.Scheme != "http" && u.Scheme != "https" {
|
||||||
|
return fmt.Errorf("base_url должен быть http/https, получено %q", u.Scheme)
|
||||||
|
}
|
||||||
|
if len(c.AllowHosts) > 0 && !containsFold(c.AllowHosts, u.Hostname()) {
|
||||||
|
return fmt.Errorf("хост %q не в allow_hosts (%s)", u.Hostname(), strings.Join(c.AllowHosts, ", "))
|
||||||
|
}
|
||||||
|
|
||||||
|
s.BaseURL = strings.TrimRight(s.BaseURL, "/")
|
||||||
|
s.TimeoutSec = orDefault(s.TimeoutSec, DefaultTimeoutSec)
|
||||||
|
s.UploadTimeoutSec = orDefault(s.UploadTimeoutSec, DefaultUploadTimeoutSec)
|
||||||
|
s.TaskPollMaxSec = orDefault(s.TaskPollMaxSec, DefaultTaskPollMaxSec)
|
||||||
|
s.TaskPollIntervalSec = orDefault(s.TaskPollIntervalSec, DefaultTaskPollIntervalSec)
|
||||||
|
s.MaxOutputBytes = orDefault(s.MaxOutputBytes, DefaultMaxOutputBytes)
|
||||||
|
s.MaxConcurrentMutations = orDefault(s.MaxConcurrentMutations, DefaultMaxConcurrentMutations)
|
||||||
|
if s.MaxUploadBytes <= 0 {
|
||||||
|
s.MaxUploadBytes = DefaultMaxUploadBytes
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Server возвращает сервер по алиасу (пустой — default).
|
||||||
|
func (c *Config) Server(alias string) (*Server, error) {
|
||||||
|
if alias == "" {
|
||||||
|
alias = c.Default
|
||||||
|
}
|
||||||
|
for i := range c.Servers {
|
||||||
|
if c.Servers[i].Alias == alias {
|
||||||
|
return &c.Servers[i], nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("неизвестный сервер %q; настроены: %s (default: %s)",
|
||||||
|
alias, strings.Join(c.ServerNames(), ", "), c.Default)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServerNames возвращает список алиасов (для сообщений об ошибках).
|
||||||
|
func (c *Config) ServerNames() []string {
|
||||||
|
out := make([]string, 0, len(c.Servers))
|
||||||
|
for _, s := range c.Servers {
|
||||||
|
out = append(out, s.Alias)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Redacted возвращает копию конфига с замаскированными секретами — для
|
||||||
|
// `--check-config` и диагностики. Пароли/токены не покидают процесс.
|
||||||
|
func (c *Config) Redacted() map[string]any {
|
||||||
|
servers := make([]map[string]any, 0, len(c.Servers))
|
||||||
|
for _, s := range c.Servers {
|
||||||
|
servers = append(servers, map[string]any{
|
||||||
|
"alias": s.Alias,
|
||||||
|
"base_url": s.BaseURL,
|
||||||
|
"username": s.Username,
|
||||||
|
"password": mask(s.Password),
|
||||||
|
"readonly_username": s.ReadonlyUsername,
|
||||||
|
"readonly_password": mask(s.ReadonlyPassword),
|
||||||
|
"gitlab_token": mask(s.GitlabToken),
|
||||||
|
"insecure_skip_verify": s.InsecureSkipVerify,
|
||||||
|
"timeout_sec": s.TimeoutSec,
|
||||||
|
"upload_timeout_sec": s.UploadTimeoutSec,
|
||||||
|
"task_poll_max_sec": s.TaskPollMaxSec,
|
||||||
|
"task_poll_interval_sec": s.TaskPollIntervalSec,
|
||||||
|
"max_output_bytes": s.MaxOutputBytes,
|
||||||
|
"max_upload_bytes": s.MaxUploadBytes,
|
||||||
|
"max_concurrent_mutations": s.MaxConcurrentMutations,
|
||||||
|
"front_roots": s.FrontRoots,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return map[string]any{
|
||||||
|
"servers": servers,
|
||||||
|
"default": c.Default,
|
||||||
|
"read_only": c.ReadOnly,
|
||||||
|
"allow_hosts": c.AllowHosts,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func orDefault(v, def int) int {
|
||||||
|
if v <= 0 {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
func mask(secret string) string {
|
||||||
|
if secret == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return "***"
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsFold(list []string, value string) bool {
|
||||||
|
for _, item := range list {
|
||||||
|
if strings.EqualFold(item, value) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
package configserver
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseConfigDefaults(t *testing.T) {
|
||||||
|
cfg, err := ParseConfig([]byte(`{"servers":[{"alias":"a","base_url":"http://h:5005","username":"u","password":"p"}]}`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseConfig: %v", err)
|
||||||
|
}
|
||||||
|
if !cfg.ReadOnly {
|
||||||
|
t.Fatalf("read_only должен по умолчанию быть true (fail-safe)")
|
||||||
|
}
|
||||||
|
if cfg.Default != "a" {
|
||||||
|
t.Fatalf("default = %q, ожидалось a", cfg.Default)
|
||||||
|
}
|
||||||
|
srv, err := cfg.Server("")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Server: %v", err)
|
||||||
|
}
|
||||||
|
if srv.TimeoutSec != DefaultTimeoutSec || srv.MaxOutputBytes != DefaultMaxOutputBytes {
|
||||||
|
t.Fatalf("дефолты лимитов не применены: %+v", srv)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSingleServerShorthand(t *testing.T) {
|
||||||
|
cfg, err := ParseConfig([]byte(`{"base_url":"http://h:5005","username":"u","password":"p"}`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseConfig: %v", err)
|
||||||
|
}
|
||||||
|
if len(cfg.Servers) != 1 || cfg.Servers[0].Alias != "default" {
|
||||||
|
t.Fatalf("shorthand не сработал: %+v", cfg.Servers)
|
||||||
|
}
|
||||||
|
if cfg.Default != "default" {
|
||||||
|
t.Fatalf("default = %q", cfg.Default)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnresolvedVarFails(t *testing.T) {
|
||||||
|
t.Setenv("OMNI_PRESENT", "x")
|
||||||
|
_ = os.Unsetenv("OMNI_MISSING")
|
||||||
|
_, err := ParseConfig([]byte(`{"servers":[{"alias":"a","base_url":"${OMNI_MISSING}","username":"u","password":"p"}]}`))
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("ожидалась ошибка о незаданной переменной")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnknownFieldFails(t *testing.T) {
|
||||||
|
_, err := ParseConfig([]byte(`{"servers":[{"alias":"a","base_url":"http://h:5005"}],"typo_field":1}`))
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("ожидалась ошибка строгого разбора неизвестного поля")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHostAllowlist(t *testing.T) {
|
||||||
|
_, err := ParseConfig([]byte(`{"servers":[{"alias":"a","base_url":"http://evil:5005"}],"allow_hosts":["good"]}`))
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("ожидалась ошибка: хост вне allow_hosts")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServerUnknownAlias(t *testing.T) {
|
||||||
|
cfg, err := ParseConfig([]byte(`{"servers":[{"alias":"a","base_url":"http://h:5005"}]}`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := cfg.Server("nope"); err == nil {
|
||||||
|
t.Fatal("ожидалась ошибка неизвестного алиаса")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadFileMergesLocal(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
main := filepath.Join(dir, "config.json")
|
||||||
|
local := filepath.Join(dir, "config.local.json")
|
||||||
|
|
||||||
|
if err := os.WriteFile(main, []byte(`{
|
||||||
|
"servers":[{"alias":"prod","base_url":"http://example:5005","username":"u","password":"p"}],
|
||||||
|
"read_only": true
|
||||||
|
}`), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(local, []byte(`{
|
||||||
|
"servers":[{"alias":"prod","base_url":"http://127.0.0.1:5005","password":"secret"}],
|
||||||
|
"read_only": false
|
||||||
|
}`), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := LoadFile(main)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadFile: %v", err)
|
||||||
|
}
|
||||||
|
if cfg.ReadOnly {
|
||||||
|
t.Fatal("read_only из local не применён")
|
||||||
|
}
|
||||||
|
srv := cfg.Servers[0]
|
||||||
|
if srv.BaseURL != "http://127.0.0.1:5005" {
|
||||||
|
t.Fatalf("base_url не переопределён: %q", srv.BaseURL)
|
||||||
|
}
|
||||||
|
if srv.Username != "u" {
|
||||||
|
t.Fatalf("username из main потерян: %q", srv.Username)
|
||||||
|
}
|
||||||
|
if srv.Password != "secret" {
|
||||||
|
t.Fatalf("password из local не применён: %q", srv.Password)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package configserver
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Сентинельные ошибки домена. MCP-слой различает их, чтобы выбрать между
|
||||||
|
// recoverable-ответом (`errorResult`) и инфраструктурной Go-ошибкой.
|
||||||
|
var (
|
||||||
|
// ErrNoConfig — не найден ни один конфиг (ни `-config`, ни OMNI_CONFIG_DIR).
|
||||||
|
ErrNoConfig = errors.New("конфиг не найден: задайте -config или per-agent omnichannel-mcp.json")
|
||||||
|
// ErrReadOnly — модуль в режиме только-чтение, мутация запрещена.
|
||||||
|
ErrReadOnly = errors.New("модуль в режиме read_only: изменяющие инструменты отключены")
|
||||||
|
// ErrConfirmRequired — разрушительная операция без confirm=\"true\".
|
||||||
|
ErrConfirmRequired = errors.New("операция требует confirm=\"true\"")
|
||||||
|
// ErrNoFrontRoots — не заданы front_roots, загрузка фронта запрещена.
|
||||||
|
ErrNoFrontRoots = errors.New("загрузка фронта выключена: не заданы front_roots в конфиге")
|
||||||
|
)
|
||||||
|
|
||||||
|
// APIError — доменная ошибка API config_server (4xx/5xx с телом {"error": ...}).
|
||||||
|
// Такие ошибки показываются модели (recoverable), чтобы она могла исправиться.
|
||||||
|
type APIError struct {
|
||||||
|
Status int
|
||||||
|
Message string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *APIError) Error() string {
|
||||||
|
if e.Message == "" {
|
||||||
|
return fmt.Sprintf("config_server вернул HTTP %d", e.Status)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("config_server (HTTP %d): %s", e.Status, e.Message)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidationError — доменная ошибка модуля (невалидный путь, лимит, политика).
|
||||||
|
// Тоже recoverable: модель может исправить аргументы.
|
||||||
|
type ValidationError struct {
|
||||||
|
Msg string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *ValidationError) Error() string { return e.Msg }
|
||||||
|
|
||||||
|
// validationErrorf создаёт ValidationError с форматированием.
|
||||||
|
func validationErrorf(format string, args ...any) error {
|
||||||
|
return &ValidationError{Msg: fmt.Sprintf(format, args...)}
|
||||||
|
}
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
package configserver
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.totmin.ru/en2zmax/forge-toolkit/configreload"
|
||||||
|
)
|
||||||
|
|
||||||
|
// tenantFileName — имя файла конфига внутри каталога OMNI_CONFIG_DIR
|
||||||
|
// (например, при постраничной/per-call передаче конфига).
|
||||||
|
const tenantFileName = "omnichannel-mcp.json"
|
||||||
|
|
||||||
|
// Manager — одиночный потокобезопасный диспетчер процесса (ARCHITECTURE.md §2).
|
||||||
|
// Кэширует per-путь загрузчики конфига (live-reload по контент-хэшу), сессии
|
||||||
|
// (отдельно rw/ro) и семафоры на мутации. Конкурентный доступ агентов безопасен.
|
||||||
|
type Manager struct {
|
||||||
|
staticPath string
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
loaders map[string]*configreload.Loader[*Config]
|
||||||
|
sessions map[string]*Session
|
||||||
|
sems map[string]chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewManager создаёт Manager. staticPath — путь из `-config` (основной режим);
|
||||||
|
// пусто — путь ищется в OMNI_CONFIG_DIR.
|
||||||
|
func NewManager(staticPath string) *Manager {
|
||||||
|
return &Manager{
|
||||||
|
staticPath: staticPath,
|
||||||
|
loaders: map[string]*configreload.Loader[*Config]{},
|
||||||
|
sessions: map[string]*Session{},
|
||||||
|
sems: map[string]chan struct{}{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolvePath выбирает путь конфига по приоритету: явный путь вызова →
|
||||||
|
// каталог из окружения (OMNI_CONFIG_DIR, затем legacy FORGE_TENANT_CONFIG) →
|
||||||
|
// статический `-config`. Если переменная задаёт каталог, в нём берётся
|
||||||
|
// tenantFileName.
|
||||||
|
func (m *Manager) ResolvePath(tenantPath string) string {
|
||||||
|
if tenantPath != "" {
|
||||||
|
return tenantPath
|
||||||
|
}
|
||||||
|
for _, env := range []string{"OMNI_CONFIG_DIR", "FORGE_TENANT_CONFIG"} {
|
||||||
|
if dir := os.Getenv(env); dir != "" {
|
||||||
|
return filepath.Join(dir, tenantFileName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return m.staticPath
|
||||||
|
}
|
||||||
|
|
||||||
|
// Config загружает конфиг (live-reload) и возвращает его вместе с путём.
|
||||||
|
// Отсутствие файла — fail-closed (ErrNoConfig); битый файл с last-good —
|
||||||
|
// работаем со старым значением и логируем.
|
||||||
|
func (m *Manager) Config(tenantPath string) (*Config, string, error) {
|
||||||
|
path := m.ResolvePath(tenantPath)
|
||||||
|
if path == "" {
|
||||||
|
return nil, "", ErrNoConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
loader := m.loaderFor(path)
|
||||||
|
cfg, err := loader.Get()
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, configreload.ErrNotFound) {
|
||||||
|
return nil, path, fmt.Errorf("%w (файл %s)", ErrNoConfig, path)
|
||||||
|
}
|
||||||
|
if cfg == nil {
|
||||||
|
return nil, path, err
|
||||||
|
}
|
||||||
|
// last-good: продолжаем на прежнем конфиге, но сигналим в лог.
|
||||||
|
slog.Warn("конфиг изменён к невалидному, работаем на последнем рабочем", "path", path, "error", err)
|
||||||
|
}
|
||||||
|
return cfg, path, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open собирает фасад API для вызова: резолвит конфиг и сервер, проверяет
|
||||||
|
// read_only, поднимает сессию и (для мутаций) берёт семафор конкурентности.
|
||||||
|
// Close обязателен.
|
||||||
|
func (m *Manager) Open(ctx context.Context, tenantPath, alias string, write bool) (*API, error) {
|
||||||
|
cfg, path, err := m.Config(tenantPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
srv, err := cfg.Server(alias)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if write && cfg.ReadOnly {
|
||||||
|
return nil, ErrReadOnly
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read-инструменты используют read-only креды, если они заданы.
|
||||||
|
sess, err := m.session(path, srv, !write)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
api := &API{
|
||||||
|
CfgPath: path,
|
||||||
|
Srv: *srv,
|
||||||
|
Config: cfg,
|
||||||
|
Sess: sess,
|
||||||
|
Write: write,
|
||||||
|
}
|
||||||
|
if write {
|
||||||
|
sem := m.semaphore(path, srv.Alias, srv.MaxConcurrentMutations)
|
||||||
|
select {
|
||||||
|
case sem <- struct{}{}:
|
||||||
|
api.sem, api.held = sem, true
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nil, ctx.Err()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return api, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// loaderFor возвращает (и кэширует) live-reload загрузчик по пути.
|
||||||
|
func (m *Manager) loaderFor(path string) *configreload.Loader[*Config] {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
if l, ok := m.loaders[path]; ok {
|
||||||
|
return l
|
||||||
|
}
|
||||||
|
l := configreload.New(path, func([]byte) (*Config, error) { return LoadFile(path) })
|
||||||
|
m.loaders[path] = l
|
||||||
|
return l
|
||||||
|
}
|
||||||
|
|
||||||
|
// session возвращает (и кэширует) сессию по (путь, алиас, rw/ro).
|
||||||
|
func (m *Manager) session(path string, srv *Server, readonly bool) (*Session, error) {
|
||||||
|
key := path + "|" + srv.Alias + "|" + strconv.FormatBool(readonly)
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
if s, ok := m.sessions[key]; ok {
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
s, err := NewSession(srv, readonly)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
m.sessions[key] = s
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// semaphore возвращает (и кэширует) семафор конкурентных мутаций на сервер.
|
||||||
|
func (m *Manager) semaphore(path, alias string, n int) chan struct{} {
|
||||||
|
if n < 1 {
|
||||||
|
n = 1
|
||||||
|
}
|
||||||
|
key := path + "|" + alias
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
ch, ok := m.sems[key]
|
||||||
|
if !ok || cap(ch) != n {
|
||||||
|
ch = make(chan struct{}, n)
|
||||||
|
m.sems[key] = ch
|
||||||
|
}
|
||||||
|
return ch
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close закрывает все сессии (graceful shutdown).
|
||||||
|
func (m *Manager) Close() {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
m.sessions = nil
|
||||||
|
m.sems = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// API — фасад одного вызова: конфиг сервера + авторизованная сессия + лимиты.
|
||||||
|
// Методы API (см. api_*.go) соответствуют эндпоинтам config_server v1.1.0.
|
||||||
|
type API struct {
|
||||||
|
CfgPath string
|
||||||
|
Srv Server
|
||||||
|
Config *Config
|
||||||
|
Sess *Session
|
||||||
|
Write bool
|
||||||
|
|
||||||
|
sem chan struct{}
|
||||||
|
held bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close освобождает семафор мутаций (если он был взят). Идемпотентен.
|
||||||
|
func (a *API) Close() {
|
||||||
|
if a.held {
|
||||||
|
<-a.sem
|
||||||
|
a.held = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alias возвращает алиас сервера (для сообщений).
|
||||||
|
func (a *API) Alias() string { return a.Srv.Alias }
|
||||||
|
|
||||||
|
// withTimeout накладывает таймаут сервера на вызов.
|
||||||
|
func (a *API) withTimeout(ctx context.Context) (context.Context, context.CancelFunc) {
|
||||||
|
return context.WithTimeout(ctx, time.Duration(a.Srv.TimeoutSec)*time.Second)
|
||||||
|
}
|
||||||
|
|
||||||
|
// withUploadTimeout — увеличенный таймаут для загрузки фронта.
|
||||||
|
func (a *API) withUploadTimeout(ctx context.Context) (context.Context, context.CancelFunc) {
|
||||||
|
return context.WithTimeout(ctx, time.Duration(a.Srv.UploadTimeoutSec)*time.Second)
|
||||||
|
}
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
package configserver
|
||||||
|
|
||||||
|
import "encoding/json"
|
||||||
|
|
||||||
|
// Типизированные модели ответов config_server v1.1.0. Используются и для
|
||||||
|
// разбора, и для отдачи модели — так схема ответа остаётся явной и стабильной.
|
||||||
|
|
||||||
|
// Application — краткая запись сервиса (GET /api/applications).
|
||||||
|
type Application struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
HostIP string `json:"host_ip"`
|
||||||
|
Hostname string `json:"hostname"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Active bool `json:"active"`
|
||||||
|
IsFront bool `json:"is_front"`
|
||||||
|
BuildFolder string `json:"build_folder"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
UpdatedAt string `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ComposeVersionRef — ссылка на версию compose в детализации приложения.
|
||||||
|
type ComposeVersionRef struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
Version int `json:"version"`
|
||||||
|
IsCurrent bool `json:"is_current"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MigrationLog — запись журнала миграций приложения.
|
||||||
|
type MigrationLog struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
Command string `json:"command"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Output string `json:"output"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ApplicationDetail — расширенная детализация (GET /api/application/<id>).
|
||||||
|
type ApplicationDetail struct {
|
||||||
|
Application
|
||||||
|
CurrentCompose string `json:"current_compose"`
|
||||||
|
ComposeVersions []ComposeVersionRef `json:"compose_versions"`
|
||||||
|
EnvFiles []string `json:"env_files"`
|
||||||
|
CurrentEnvFiles map[string]string `json:"current_env_files"`
|
||||||
|
MigrationLogs []MigrationLog `json:"migration_logs"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RawConfig — текущие compose/env приложения (GET /api/get_config/<id>).
|
||||||
|
type RawConfig struct {
|
||||||
|
Compose string `json:"compose"`
|
||||||
|
Env string `json:"env"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ComposeVersion — конкретная версия compose (GET /api/compose_version/<id>).
|
||||||
|
type ComposeVersion struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
Version int `json:"version"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
IsCurrent bool `json:"is_current"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnvVersion — конкретная версия env-файла (GET /api/env_version/<id>).
|
||||||
|
type EnvVersion struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
Filename string `json:"filename"`
|
||||||
|
Version int `json:"version"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
IsCurrent bool `json:"is_current"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Task — задача агента (GET /api/tasks, /api/task/<id>, /api/deployment/tasks).
|
||||||
|
type Task struct {
|
||||||
|
TaskID string `json:"task_id"`
|
||||||
|
ApplicationID int `json:"application_id"`
|
||||||
|
ApplicationName string `json:"application_name"`
|
||||||
|
TaskType string `json:"task_type"`
|
||||||
|
Command string `json:"command"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
HostIP string `json:"host_ip"`
|
||||||
|
Payload json.RawMessage `json:"payload"`
|
||||||
|
Output string `json:"output"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
CompletedAt string `json:"completed_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stats — счётчики версий (GET /api/stats).
|
||||||
|
type Stats struct {
|
||||||
|
ComposeVersions int `json:"compose_versions"`
|
||||||
|
EnvVersions int `json:"env_versions"`
|
||||||
|
MigrationLogs int `json:"migration_logs"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServiceInfo — сервис в карте серверов.
|
||||||
|
type ServiceInfo struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Active bool `json:"active"`
|
||||||
|
UpdatedAt string `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServerNode — хост и его сервисы в карте (GET /api/service_map).
|
||||||
|
type ServerNode struct {
|
||||||
|
HostIP string `json:"host_ip"`
|
||||||
|
Hostname string `json:"hostname"`
|
||||||
|
TotalServices int `json:"total_services"`
|
||||||
|
ActiveServices int `json:"active_services"`
|
||||||
|
Services []ServiceInfo `json:"services"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServiceMap — ответ GET /api/service_map.
|
||||||
|
type ServiceMap struct {
|
||||||
|
Servers []ServerNode `json:"servers"`
|
||||||
|
ServersCount int `json:"servers_count"`
|
||||||
|
ServicesCount int `json:"services_count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeploymentFiles — schema/manifest/prebuild_vars/ip_overrides.
|
||||||
|
type DeploymentFiles struct {
|
||||||
|
Schema string `json:"schema"`
|
||||||
|
Manifest string `json:"manifest"`
|
||||||
|
PrebuildVars string `json:"prebuild_vars"`
|
||||||
|
IPOverrides string `json:"ip_overrides"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReleaseJob — статус фоновой загрузки релиза (GET /api/deployment/release_job).
|
||||||
|
type ReleaseJob struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
Phase string `json:"phase"`
|
||||||
|
ImagesPulled int `json:"images_pulled"`
|
||||||
|
ImagesTotal int `json:"images_total"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Output string `json:"output"`
|
||||||
|
Stderr string `json:"stderr"`
|
||||||
|
UpdatedAt string `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// IPMatch — диагностика сопоставления IP схемы и агентов.
|
||||||
|
type IPMatch struct {
|
||||||
|
SchemaIPs []string `json:"schema_ips"`
|
||||||
|
AgentIPsInDB []string `json:"agent_ips_in_db"`
|
||||||
|
IPOverrides map[string]string `json:"ip_overrides"`
|
||||||
|
MatchedPairs map[string]string `json:"matched_pairs"`
|
||||||
|
UnmatchedSchemaIPs []string `json:"unmatched_schema_ips"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TaskAck — ответ операций жизненного цикла (deploy/restart/down/migrate/update_config).
|
||||||
|
type TaskAck struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
AppID int `json:"app_id"`
|
||||||
|
AppName string `json:"app_name"`
|
||||||
|
HostIP string `json:"host_ip"`
|
||||||
|
TaskID string `json:"task_id"`
|
||||||
|
LogID int `json:"log_id"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConfigAck — ответ установки compose/env.
|
||||||
|
type ConfigAck struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
AppID int `json:"app_id"`
|
||||||
|
Version int `json:"version"`
|
||||||
|
Filename string `json:"filename"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// StatusAck — простой ответ {"status": "..."} c опциональным сообщением.
|
||||||
|
type StatusAck struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// FrontAck — ответ update_front.
|
||||||
|
type FrontAck struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
AppID int `json:"app_id"`
|
||||||
|
BuildFolder string `json:"build_folder"`
|
||||||
|
TaskID string `json:"task_id"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CleanupAck — ответ cleanup_old_versions.
|
||||||
|
type CleanupAck struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
DeletedComposeVersions int `json:"deleted_compose_versions"`
|
||||||
|
DeletedEnvVersions int `json:"deleted_env_versions"`
|
||||||
|
DeletedMigrationLogs int `json:"deleted_migration_logs"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SeedHostsAck — ответ seed_hosts.
|
||||||
|
type SeedHostsAck struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
Created int `json:"created"`
|
||||||
|
Updated int `json:"updated"`
|
||||||
|
IPs []string `json:"ips"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DownloadReleaseAck — ответ download_release (синхронный и фоновый).
|
||||||
|
type DownloadReleaseAck struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
LoadImages bool `json:"load_images"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
DownloadedServices []string `json:"downloaded_services"`
|
||||||
|
SyncTaskIDs []string `json:"sync_task_ids"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartServicesAck — ответ start_services.
|
||||||
|
type StartServicesAck struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
StartTaskIDs []string `json:"start_task_ids"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,354 @@
|
|||||||
|
package configserver
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"mime/multipart"
|
||||||
|
"net/http"
|
||||||
|
"net/http/cookiejar"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// maxResponseBytes — жёсткий предохранитель на размер тела ответа (защита от
|
||||||
|
// OOM). Доменные ответы (compose/env/schema) заметно меньше.
|
||||||
|
const maxResponseBytes = 16 << 20 // 16 MiB
|
||||||
|
|
||||||
|
// Session — авторизованная HTTP-сессия к одному config_server.
|
||||||
|
//
|
||||||
|
// Авторизация устроена как Flask-сессия: login кладёт подписанную cookie, она
|
||||||
|
// хранится в cookiejar и автоматически прикладывается к запросам. Сессия
|
||||||
|
// потокобезопасна: login выполняется один раз (single-flight), при 401 cookie
|
||||||
|
// пересоздаётся и запрос повторяется ровно один раз.
|
||||||
|
type Session struct {
|
||||||
|
baseURL string
|
||||||
|
username string
|
||||||
|
password string
|
||||||
|
label string // alias (+ "(ro)") для сообщений об ошибках
|
||||||
|
|
||||||
|
client *http.Client
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
loggedIn bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// response — низкоуровневый ответ (тело уже прочитано и ограничено).
|
||||||
|
type response struct {
|
||||||
|
status int
|
||||||
|
body []byte
|
||||||
|
ctype string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSession создаёт сессию для сервера. readonly=true выбирает отдельную
|
||||||
|
// read-only учётную запись, если она задана (иначе — основная).
|
||||||
|
func NewSession(srv *Server, readonly bool) (*Session, error) {
|
||||||
|
user, pass := srv.Username, srv.Password
|
||||||
|
label := srv.Alias
|
||||||
|
if readonly && srv.ReadonlyUsername != "" {
|
||||||
|
user, pass = srv.ReadonlyUsername, srv.ReadonlyPassword
|
||||||
|
label += "(ro)"
|
||||||
|
}
|
||||||
|
|
||||||
|
jar, err := cookiejar.New(nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("cookie jar: %w", err)
|
||||||
|
}
|
||||||
|
// #nosec G402 — InsecureSkipVerify включается осознанно оператором для
|
||||||
|
// стендов с самоподписанными сертификатами (флаг в конфиге).
|
||||||
|
transport := &http.Transport{
|
||||||
|
TLSClientConfig: &tls.Config{InsecureSkipVerify: srv.InsecureSkipVerify},
|
||||||
|
}
|
||||||
|
return &Session{
|
||||||
|
baseURL: srv.BaseURL,
|
||||||
|
username: user,
|
||||||
|
password: pass,
|
||||||
|
label: label,
|
||||||
|
client: &http.Client{Jar: jar, Transport: transport},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// BaseURL возвращает адрес сервера сессии.
|
||||||
|
func (s *Session) BaseURL() string { return s.baseURL }
|
||||||
|
|
||||||
|
// ensureLogin выполняет login, если сессия ещё не авторизована.
|
||||||
|
func (s *Session) ensureLogin(ctx context.Context) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
if s.loggedIn {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return s.loginLocked(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// loginLocked — login под уже взятым мьютексом (single-flight).
|
||||||
|
func (s *Session) loginLocked(ctx context.Context) error {
|
||||||
|
if s.username == "" || s.password == "" {
|
||||||
|
return &APIError{Status: http.StatusUnauthorized,
|
||||||
|
Message: fmt.Sprintf("для сервера %s не заданы username/password", s.label)}
|
||||||
|
}
|
||||||
|
resp, err := s.once(ctx, http.MethodPost, "/api/login", nil,
|
||||||
|
map[string]string{"username": s.username, "password": s.password})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := classify(resp); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s.loggedIn = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// invalidate сбрасывает признак авторизации (cookie могла истечь).
|
||||||
|
func (s *Session) invalidate() {
|
||||||
|
s.mu.Lock()
|
||||||
|
s.loggedIn = false
|
||||||
|
s.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// getJSON выполняет авторизованный GET и разбирает JSON в out.
|
||||||
|
func (s *Session) getJSON(ctx context.Context, path string, query url.Values, out any) error {
|
||||||
|
resp, err := s.do(ctx, http.MethodGet, path, query, nil, true)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := classify(resp); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return decodeJSON(resp, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// postJSON выполняет авторизованный POST с JSON-телом и разбирает ответ.
|
||||||
|
func (s *Session) postJSON(ctx context.Context, path string, body, out any) error {
|
||||||
|
resp, err := s.do(ctx, http.MethodPost, path, nil, body, true)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := classify(resp); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return decodeJSON(resp, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// getPublic выполняет GET без авторизации (например, /health).
|
||||||
|
func (s *Session) getPublic(ctx context.Context, path string) (*response, error) {
|
||||||
|
return s.do(ctx, http.MethodGet, path, nil, nil, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// do выполняет запрос с автоматическим повтором: один релогin при 401 и
|
||||||
|
// (только для GET) один повтор при сетевом сбое или 5xx. Мутации НЕ
|
||||||
|
// повторяются, чтобы не выполнить действие дважды.
|
||||||
|
func (s *Session) do(ctx context.Context, method, path string, query url.Values, body any, authenticated bool) (*response, error) {
|
||||||
|
if authenticated {
|
||||||
|
if err := s.ensureLogin(ctx); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
attempts := 1
|
||||||
|
if method == http.MethodGet {
|
||||||
|
attempts = 2
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp *response
|
||||||
|
for attempt := 1; attempt <= attempts; attempt++ {
|
||||||
|
r, err := s.once(ctx, method, path, query, body)
|
||||||
|
if err != nil {
|
||||||
|
if attempt < attempts && ctx.Err() == nil {
|
||||||
|
if err := sleepBackoff(ctx, attempt); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
resp = r
|
||||||
|
|
||||||
|
if authenticated && resp.status == http.StatusUnauthorized {
|
||||||
|
// Cookie-сессия истекла: перелогиниваемся и повторяем ровно один раз.
|
||||||
|
s.invalidate()
|
||||||
|
if lerr := s.ensureLogin(ctx); lerr != nil {
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
if r2, err2 := s.once(ctx, method, path, query, body); err2 == nil {
|
||||||
|
return r2, nil
|
||||||
|
}
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
if attempt < attempts && resp.status >= 500 {
|
||||||
|
if err := sleepBackoff(ctx, attempt); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// once выполняет ровно один HTTP-запрос.
|
||||||
|
func (s *Session) once(ctx context.Context, method, path string, query url.Values, body any) (*response, error) {
|
||||||
|
u := s.baseURL + path
|
||||||
|
if len(query) > 0 {
|
||||||
|
u += "?" + query.Encode()
|
||||||
|
}
|
||||||
|
|
||||||
|
var reader io.Reader
|
||||||
|
if body != nil {
|
||||||
|
raw, err := json.Marshal(body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("кодирование тела запроса: %w", err)
|
||||||
|
}
|
||||||
|
reader = bytes.NewReader(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, method, u, reader)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("сборка запроса: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
if body != nil {
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := s.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%s %s: %w", method, path, err)
|
||||||
|
}
|
||||||
|
defer func() { _ = res.Body.Close() }()
|
||||||
|
|
||||||
|
data, err := io.ReadAll(io.LimitReader(res.Body, maxResponseBytes))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("чтение ответа %s: %w", path, err)
|
||||||
|
}
|
||||||
|
return &response{status: res.StatusCode, body: data, ctype: res.Header.Get("Content-Type")}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// postMultipart загружает файлы как multipart/form-data (update_front). Файлы
|
||||||
|
// передаются потоково из памяти вызывающего; имена — относительные пути.
|
||||||
|
func (s *Session) postMultipart(ctx context.Context, path string, files []UploadFile) (json.RawMessage, error) {
|
||||||
|
if err := s.ensureLogin(ctx); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
writer := multipart.NewWriter(&buf)
|
||||||
|
for _, f := range files {
|
||||||
|
part, err := writer.CreateFormFile("files", f.Name)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("multipart: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := part.Write(f.Data); err != nil {
|
||||||
|
return nil, fmt.Errorf("multipart: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := writer.Close(); err != nil {
|
||||||
|
return nil, fmt.Errorf("multipart: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.baseURL+path, &buf)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("сборка запроса: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
|
||||||
|
res, err := s.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("POST %s: %w", path, err)
|
||||||
|
}
|
||||||
|
defer func() { _ = res.Body.Close() }()
|
||||||
|
|
||||||
|
data, err := io.ReadAll(io.LimitReader(res.Body, maxResponseBytes))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("чтение ответа %s: %w", path, err)
|
||||||
|
}
|
||||||
|
resp := &response{status: res.StatusCode, body: data, ctype: res.Header.Get("Content-Type")}
|
||||||
|
if err := classify(resp); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return json.RawMessage(data), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UploadFile — файл для multipart-загрузки фронта.
|
||||||
|
type UploadFile struct {
|
||||||
|
Name string
|
||||||
|
Data []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
// classify превращает HTTP-статус ≥400 в доменную APIError с текстом сервера.
|
||||||
|
// Текст берём из JSON-поля "error"; HTML/мусорные тела заменяем кратким
|
||||||
|
// сообщением, чтобы не засорять контекст модели.
|
||||||
|
func classify(resp *response) error {
|
||||||
|
if resp.status < 400 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var payload struct {
|
||||||
|
Error string `json:"error"`
|
||||||
|
}
|
||||||
|
_ = json.Unmarshal(resp.body, &payload)
|
||||||
|
msg := strings.TrimSpace(payload.Error)
|
||||||
|
if msg == "" {
|
||||||
|
msg = defaultHTTPMessage(resp.status)
|
||||||
|
}
|
||||||
|
return &APIError{Status: resp.status, Message: msg}
|
||||||
|
}
|
||||||
|
|
||||||
|
// defaultHTTPMessage — краткое человекочитаемое описание статуса.
|
||||||
|
func defaultHTTPMessage(status int) string {
|
||||||
|
switch status {
|
||||||
|
case 400:
|
||||||
|
return "неверный запрос (400)"
|
||||||
|
case 401:
|
||||||
|
return "не авторизован (401)"
|
||||||
|
case 404:
|
||||||
|
return "не найдено (404)"
|
||||||
|
case 409:
|
||||||
|
return "конфликт (409)"
|
||||||
|
case 500:
|
||||||
|
return "внутренняя ошибка сервера (500)"
|
||||||
|
default:
|
||||||
|
return fmt.Sprintf("HTTP %d", status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeJSON(resp *response, out any) error {
|
||||||
|
if out == nil || len(resp.body) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(resp.body, out); err != nil {
|
||||||
|
return fmt.Errorf("разбор ответа сервера: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// sleepBackoff делает паузу перед повтором, уважая отмену контекста.
|
||||||
|
func sleepBackoff(ctx context.Context, attempt int) error {
|
||||||
|
d := time.Duration(200*attempt) * time.Millisecond
|
||||||
|
timer := time.NewTimer(d)
|
||||||
|
defer timer.Stop()
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
case <-timer.C:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsDomainError сообщает, относится ли ошибка к доменным (config_server вернул
|
||||||
|
// 4xx/5xx, ошибка валидации модуля или политики вроде отсутствия front_roots),
|
||||||
|
// а не к инфраструктурным (сеть). Доменные — recoverable: текст видит модель.
|
||||||
|
func IsDomainError(err error) bool {
|
||||||
|
var apiErr *APIError
|
||||||
|
var valErr *ValidationError
|
||||||
|
if errors.As(err, &apiErr) || errors.As(err, &valErr) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return errors.Is(err, ErrNoFrontRoots)
|
||||||
|
}
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
package configserver
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// mockState — разделяемое состояние тестового config_server.
|
||||||
|
type mockState struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
logins int
|
||||||
|
failOnceApps bool
|
||||||
|
flakyCalls int
|
||||||
|
mutationCalls int
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTestServer(t *testing.T) (*httptest.Server, *mockState) {
|
||||||
|
t.Helper()
|
||||||
|
st := &mockState{}
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
|
||||||
|
writeJSON := func(w http.ResponseWriter, code int, body string) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(code)
|
||||||
|
_, _ = w.Write([]byte(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
mux.HandleFunc("/api/login", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var body map[string]string
|
||||||
|
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||||
|
if body["username"] != "u" || body["password"] != "p" {
|
||||||
|
writeJSON(w, http.StatusUnauthorized, `{"error":"Invalid username or password"}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
st.mu.Lock()
|
||||||
|
st.logins++
|
||||||
|
st.mu.Unlock()
|
||||||
|
http.SetCookie(w, &http.Cookie{Name: "sess", Value: "ok", Path: "/"})
|
||||||
|
writeJSON(w, http.StatusOK, `{"status":"ok","username":"u"}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
mux.HandleFunc("/api/applications", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if c, err := r.Cookie("sess"); err != nil || c.Value != "ok" {
|
||||||
|
writeJSON(w, http.StatusUnauthorized, `{"error":"Authentication required"}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
st.mu.Lock()
|
||||||
|
fail := st.failOnceApps
|
||||||
|
st.failOnceApps = false
|
||||||
|
st.mu.Unlock()
|
||||||
|
if fail {
|
||||||
|
writeJSON(w, http.StatusUnauthorized, `{"error":"Authentication required"}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, `[{"id":1,"name":"x","status":"OK"}]`)
|
||||||
|
})
|
||||||
|
|
||||||
|
mux.HandleFunc("/api/flaky", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
st.mu.Lock()
|
||||||
|
st.flakyCalls++
|
||||||
|
n := st.flakyCalls
|
||||||
|
st.mu.Unlock()
|
||||||
|
if n == 1 {
|
||||||
|
writeJSON(w, http.StatusInternalServerError, `{"error":"boom"}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, `{"ok":true}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
mux.HandleFunc("/api/mutate", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
st.mu.Lock()
|
||||||
|
st.mutationCalls++
|
||||||
|
st.mu.Unlock()
|
||||||
|
writeJSON(w, http.StatusInternalServerError, `{"error":"boom"}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
ts := httptest.NewServer(mux)
|
||||||
|
t.Cleanup(ts.Close)
|
||||||
|
return ts, st
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTestSession(t *testing.T, url string) *Session {
|
||||||
|
t.Helper()
|
||||||
|
s, err := NewSession(&Server{Alias: "t", BaseURL: url, Username: "u", Password: "p"}, false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewSession: %v", err)
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSessionLoginAndGet(t *testing.T) {
|
||||||
|
ts, st := newTestServer(t)
|
||||||
|
s := newTestSession(t, ts.URL)
|
||||||
|
|
||||||
|
var apps []Application
|
||||||
|
if err := s.getJSON(context.Background(), "/api/applications", nil, &apps); err != nil {
|
||||||
|
t.Fatalf("getJSON: %v", err)
|
||||||
|
}
|
||||||
|
if len(apps) != 1 || apps[0].Name != "x" {
|
||||||
|
t.Fatalf("неожиданный ответ: %+v", apps)
|
||||||
|
}
|
||||||
|
if st.logins != 1 {
|
||||||
|
t.Fatalf("logins = %d, ожидалось 1", st.logins)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSessionReloginOn401(t *testing.T) {
|
||||||
|
ts, st := newTestServer(t)
|
||||||
|
s := newTestSession(t, ts.URL)
|
||||||
|
st.failOnceApps = true
|
||||||
|
|
||||||
|
var apps []Application
|
||||||
|
if err := s.getJSON(context.Background(), "/api/applications", nil, &apps); err != nil {
|
||||||
|
t.Fatalf("getJSON после relogin: %v", err)
|
||||||
|
}
|
||||||
|
if st.logins != 2 {
|
||||||
|
t.Fatalf("logins = %d, ожидалось 2 (релогin при 401)", st.logins)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetRetriedOn5xx(t *testing.T) {
|
||||||
|
ts, st := newTestServer(t)
|
||||||
|
s := newTestSession(t, ts.URL)
|
||||||
|
|
||||||
|
var out map[string]any
|
||||||
|
if err := s.getJSON(context.Background(), "/api/flaky", nil, &out); err != nil {
|
||||||
|
t.Fatalf("getJSON flaky: %v", err)
|
||||||
|
}
|
||||||
|
if st.flakyCalls != 2 {
|
||||||
|
t.Fatalf("GET-повтор не сработал: calls = %d", st.flakyCalls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMutationNotRetried(t *testing.T) {
|
||||||
|
ts, st := newTestServer(t)
|
||||||
|
s := newTestSession(t, ts.URL)
|
||||||
|
|
||||||
|
var out map[string]any
|
||||||
|
err := s.postJSON(context.Background(), "/api/mutate", map[string]string{"x": "y"}, &out)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("ожидалась ошибка 500")
|
||||||
|
}
|
||||||
|
if st.mutationCalls != 1 {
|
||||||
|
t.Fatalf("мутация НЕ должна повторяться: calls = %d", st.mutationCalls)
|
||||||
|
}
|
||||||
|
if !IsDomainError(err) {
|
||||||
|
t.Fatalf("ошибка 500 должна классифицироваться как доменная: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
package tools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||||
|
)
|
||||||
|
|
||||||
|
// configure.go — изменяющие инструменты конфигурации (compose/env и версии).
|
||||||
|
// Требуют read_only=false и обычно помечаются require_approval в agent.yaml.
|
||||||
|
|
||||||
|
func registerConfigureTools(s *mcp.Server) {
|
||||||
|
addPatternTool(s, &mcp.Tool{
|
||||||
|
Name: "set_compose",
|
||||||
|
Description: "Заменить текущий docker-compose сервиса, создать версию и отправить агенту. Изменяет конфигурацию.",
|
||||||
|
InputSchema: schema(map[string]any{
|
||||||
|
"server": serverProp(),
|
||||||
|
"app_id": intProps("Идентификатор сервиса", true),
|
||||||
|
"content": strProps("Полный текст docker-compose (YAML)", true),
|
||||||
|
}, []string{"app_id", "content"}),
|
||||||
|
}, appPatterns, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
args := requestArgs(req)
|
||||||
|
id, res := appIDArg(args)
|
||||||
|
if res != nil {
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
content, err := requireString(args, "content")
|
||||||
|
if err != nil {
|
||||||
|
return errorResult(err.Error()), nil
|
||||||
|
}
|
||||||
|
api, res := openAPI(ctx, args, true)
|
||||||
|
if res != nil {
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
defer api.Close()
|
||||||
|
return result(api.SetCompose(ctx, id, content))
|
||||||
|
})
|
||||||
|
|
||||||
|
addPatternTool(s, &mcp.Tool{
|
||||||
|
Name: "set_env",
|
||||||
|
Description: "Заменить текущий env-файл сервиса (например .env), создать версию и отправить агенту. Изменяет конфигурацию.",
|
||||||
|
InputSchema: schema(map[string]any{
|
||||||
|
"server": serverProp(),
|
||||||
|
"app_id": intProps("Идентификатор сервиса", true),
|
||||||
|
"filename": strProps("Имя env-файла, например .env", true),
|
||||||
|
"content": strProps("Полное содержимое env-файла", true),
|
||||||
|
}, []string{"app_id", "filename", "content"}),
|
||||||
|
}, envPatterns, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
args := requestArgs(req)
|
||||||
|
id, res := appIDArg(args)
|
||||||
|
if res != nil {
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
filename, err := requireString(args, "filename")
|
||||||
|
if err != nil {
|
||||||
|
return errorResult(err.Error()), nil
|
||||||
|
}
|
||||||
|
content, err := requireString(args, "content")
|
||||||
|
if err != nil {
|
||||||
|
return errorResult(err.Error()), nil
|
||||||
|
}
|
||||||
|
api, res := openAPI(ctx, args, true)
|
||||||
|
if res != nil {
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
defer api.Close()
|
||||||
|
return result(api.SetEnv(ctx, id, filename, content))
|
||||||
|
})
|
||||||
|
|
||||||
|
addPatternTool(s, &mcp.Tool{
|
||||||
|
Name: "restore_compose",
|
||||||
|
Description: "Восстановить версию compose (создаёт новую текущую версию) и отправить агенту. Разрушительно: требует confirm=\"true\".",
|
||||||
|
InputSchema: schema(map[string]any{
|
||||||
|
"server": serverProp(),
|
||||||
|
"version_id": intProps("Идентификатор версии compose", true),
|
||||||
|
"confirm": boolProps("Подтверждение разрушительной операции (обязательно true)", true),
|
||||||
|
}, []string{"version_id", "confirm"}),
|
||||||
|
}, versionPatterns, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
args := requestArgs(req)
|
||||||
|
if res := confirmGate(args, "restore_compose"); res != nil {
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
id, res := versionIDArg(args)
|
||||||
|
if res != nil {
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
api, res := openAPI(ctx, args, true)
|
||||||
|
if res != nil {
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
defer api.Close()
|
||||||
|
return result(api.RestoreCompose(ctx, id))
|
||||||
|
})
|
||||||
|
|
||||||
|
addPatternTool(s, &mcp.Tool{
|
||||||
|
Name: "restore_env_version",
|
||||||
|
Description: "Восстановить версию env-файла (создаёт новую текущую версию) и отправить агенту. Разрушительно: требует confirm=\"true\".",
|
||||||
|
InputSchema: schema(map[string]any{
|
||||||
|
"server": serverProp(),
|
||||||
|
"version_id": intProps("Идентификатор версии env", true),
|
||||||
|
"confirm": boolProps("Подтверждение разрушительной операции (обязательно true)", true),
|
||||||
|
}, []string{"version_id", "confirm"}),
|
||||||
|
}, versionPatterns, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
args := requestArgs(req)
|
||||||
|
if res := confirmGate(args, "restore_env_version"); res != nil {
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
id, res := versionIDArg(args)
|
||||||
|
if res != nil {
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
api, res := openAPI(ctx, args, true)
|
||||||
|
if res != nil {
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
defer api.Close()
|
||||||
|
return result(api.RestoreEnvVersion(ctx, id))
|
||||||
|
})
|
||||||
|
|
||||||
|
addPatternTool(s, &mcp.Tool{
|
||||||
|
Name: "update_config",
|
||||||
|
Description: "Повторно отправить агенту текущую конфигурацию сервиса из БД (compose + env). Изменяет файлы на хосте.",
|
||||||
|
InputSchema: schema(map[string]any{
|
||||||
|
"server": serverProp(),
|
||||||
|
"app_id": intProps("Идентификатор сервиса", true),
|
||||||
|
}, []string{"app_id"}),
|
||||||
|
}, appPatterns, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
args := requestArgs(req)
|
||||||
|
id, res := appIDArg(args)
|
||||||
|
if res != nil {
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
api, res := openAPI(ctx, args, true)
|
||||||
|
if res != nil {
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
defer api.Close()
|
||||||
|
return result(api.UpdateConfig(ctx, id))
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package tools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||||
|
)
|
||||||
|
|
||||||
|
// maintain.go — обслуживание: очистка старых версий.
|
||||||
|
|
||||||
|
func registerMaintainTools(s *mcp.Server) {
|
||||||
|
// cleanup_old_versions не эмитит паттерн по конкретному ресурсу: операция
|
||||||
|
// затрагивает всю БД версий, поэтому используем release-паттерн стенда.
|
||||||
|
addPatternTool(s, &mcp.Tool{
|
||||||
|
Name: "cleanup_old_versions",
|
||||||
|
Description: "Удалить версии compose/env и логи миграций старше N дней (текущие версии не трогаются). Разрушительно: требует confirm=\"true\".",
|
||||||
|
InputSchema: schema(map[string]any{
|
||||||
|
"server": serverProp(),
|
||||||
|
"days": intProps("Порог в днях (по умолчанию 90)", false),
|
||||||
|
"confirm": boolProps("Подтверждение разрушительной операции (обязательно true)", true),
|
||||||
|
}, []string{"confirm"}),
|
||||||
|
}, releasePatterns, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
args := requestArgs(req)
|
||||||
|
if res := confirmGate(args, "cleanup_old_versions"); res != nil {
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
days := getInt(args, "days", 90)
|
||||||
|
if days <= 0 {
|
||||||
|
return errorResult("days должен быть положительным"), nil
|
||||||
|
}
|
||||||
|
api, res := openAPI(ctx, args, true)
|
||||||
|
if res != nil {
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
defer api.Close()
|
||||||
|
return result(api.CleanupOldVersions(ctx, days))
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,338 @@
|
|||||||
|
package tools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"omnichannel-mcp/internal/configserver"
|
||||||
|
|
||||||
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||||
|
)
|
||||||
|
|
||||||
|
// observe.go — read-only инструменты: наблюдение, диагностика, аудит.
|
||||||
|
|
||||||
|
// observeCall — общий каркас read-инструмента: открыть API, вызвать домен,
|
||||||
|
// вернуть результат. Убирает повторяющуюся обвязку из хендлеров.
|
||||||
|
func observeCall(ctx context.Context, req *mcp.CallToolRequest, call func(*configserver.API) (any, error)) (*mcp.CallToolResult, error) {
|
||||||
|
args := requestArgs(req)
|
||||||
|
api, res := openAPI(ctx, args, false)
|
||||||
|
if res != nil {
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
defer api.Close()
|
||||||
|
v, err := call(api)
|
||||||
|
return result(v, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func registerObserveTools(s *mcp.Server) {
|
||||||
|
registerHealthTools(s)
|
||||||
|
registerApplicationTools(s)
|
||||||
|
registerConfigReadTools(s)
|
||||||
|
registerTaskTools(s)
|
||||||
|
registerDeploymentReadTools(s)
|
||||||
|
registerSettingsTools(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
func registerHealthTools(s *mcp.Server) {
|
||||||
|
addTool(s, &mcp.Tool{
|
||||||
|
Name: "server_health",
|
||||||
|
Description: "Живость config_server (GET /health, без авторизации). Read-only. Помогает отличить проблему стенда от проблемы авторизации.",
|
||||||
|
InputSchema: schema(map[string]any{"server": serverProp()}, nil),
|
||||||
|
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
return observeCall(ctx, req, func(api *configserver.API) (any, error) {
|
||||||
|
status, body, err := api.Health(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return map[string]any{"server": api.Alias(), "http_status": status, "body": body}, nil
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
addTool(s, &mcp.Tool{
|
||||||
|
Name: "whoami",
|
||||||
|
Description: "Проверить авторизацию: кто залогинен на config_server. Read-only.",
|
||||||
|
InputSchema: schema(map[string]any{"server": serverProp()}, nil),
|
||||||
|
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
return observeCall(ctx, req, func(api *configserver.API) (any, error) {
|
||||||
|
return api.WhoAmI(ctx)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
addTool(s, &mcp.Tool{
|
||||||
|
Name: "server_info",
|
||||||
|
Description: "Сведения о сервере и версии API (capability-probe): алиас, адрес, режим read_only, доступные JSON-эндпоинты. Read-only. Вызывай первым при неясностях.",
|
||||||
|
InputSchema: schema(map[string]any{"server": serverProp()}, nil),
|
||||||
|
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
return observeCall(ctx, req, func(api *configserver.API) (any, error) {
|
||||||
|
caps, err := api.Probe(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return map[string]any{
|
||||||
|
"server": api.Alias(),
|
||||||
|
"base_url": api.Srv.BaseURL,
|
||||||
|
"read_only": api.Config.ReadOnly,
|
||||||
|
"readonly_creds": api.Srv.ReadonlyUsername != "",
|
||||||
|
"capabilities": caps,
|
||||||
|
}, nil
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func registerApplicationTools(s *mcp.Server) {
|
||||||
|
addTool(s, &mcp.Tool{
|
||||||
|
Name: "list_applications",
|
||||||
|
Description: "Список сервисов с хостом и статусом. Read-only.",
|
||||||
|
InputSchema: schema(map[string]any{
|
||||||
|
"server": serverProp(),
|
||||||
|
"include_presence": boolProps("Включить служебные записи хостов (__agent_presence__)", false),
|
||||||
|
}, nil),
|
||||||
|
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
args := requestArgs(req)
|
||||||
|
return observeCall(ctx, req, func(api *configserver.API) (any, error) {
|
||||||
|
return api.ListApplications(ctx, getBool(args, "include_presence", false))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
addTool(s, &mcp.Tool{
|
||||||
|
Name: "get_application",
|
||||||
|
Description: "Детализация сервиса: статус, текущий compose, версии compose, env-файлы и их содержимое, журнал миграций. Read-only.",
|
||||||
|
InputSchema: schema(map[string]any{
|
||||||
|
"server": serverProp(),
|
||||||
|
"app_id": intProps("Идентификатор сервиса", true),
|
||||||
|
}, []string{"app_id"}),
|
||||||
|
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
args := requestArgs(req)
|
||||||
|
id, res := appIDArg(args)
|
||||||
|
if res != nil {
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
return observeCall(ctx, req, func(api *configserver.API) (any, error) {
|
||||||
|
return api.GetApplication(ctx, id)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func registerConfigReadTools(s *mcp.Server) {
|
||||||
|
addTool(s, &mcp.Tool{
|
||||||
|
Name: "get_config",
|
||||||
|
Description: "Текущие compose и env сервиса из БД. Read-only.",
|
||||||
|
InputSchema: schema(map[string]any{
|
||||||
|
"server": serverProp(),
|
||||||
|
"app_id": intProps("Идентификатор сервиса", true),
|
||||||
|
}, []string{"app_id"}),
|
||||||
|
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
args := requestArgs(req)
|
||||||
|
id, res := appIDArg(args)
|
||||||
|
if res != nil {
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
return observeCall(ctx, req, func(api *configserver.API) (any, error) {
|
||||||
|
return api.GetConfig(ctx, id)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
addTool(s, &mcp.Tool{
|
||||||
|
Name: "env_files",
|
||||||
|
Description: "Список текущих env-файлов сервиса. Read-only.",
|
||||||
|
InputSchema: schema(map[string]any{
|
||||||
|
"server": serverProp(),
|
||||||
|
"app_id": intProps("Идентификатор сервиса", true),
|
||||||
|
}, []string{"app_id"}),
|
||||||
|
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
args := requestArgs(req)
|
||||||
|
id, res := appIDArg(args)
|
||||||
|
if res != nil {
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
return observeCall(ctx, req, func(api *configserver.API) (any, error) {
|
||||||
|
return api.EnvFiles(ctx, id)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
addTool(s, &mcp.Tool{
|
||||||
|
Name: "compose_version",
|
||||||
|
Description: "Содержимое конкретной версии compose по version_id. Read-only.",
|
||||||
|
InputSchema: schema(map[string]any{
|
||||||
|
"server": serverProp(),
|
||||||
|
"version_id": intProps("Идентификатор версии compose", true),
|
||||||
|
}, []string{"version_id"}),
|
||||||
|
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
args := requestArgs(req)
|
||||||
|
id, res := versionIDArg(args)
|
||||||
|
if res != nil {
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
return observeCall(ctx, req, func(api *configserver.API) (any, error) {
|
||||||
|
return api.ComposeVersion(ctx, id)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
addTool(s, &mcp.Tool{
|
||||||
|
Name: "env_version",
|
||||||
|
Description: "Содержимое конкретной версии env-файла по version_id. Read-only.",
|
||||||
|
InputSchema: schema(map[string]any{
|
||||||
|
"server": serverProp(),
|
||||||
|
"version_id": intProps("Идентификатор версии env", true),
|
||||||
|
}, []string{"version_id"}),
|
||||||
|
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
args := requestArgs(req)
|
||||||
|
id, res := versionIDArg(args)
|
||||||
|
if res != nil {
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
return observeCall(ctx, req, func(api *configserver.API) (any, error) {
|
||||||
|
return api.EnvVersion(ctx, id)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
addTool(s, &mcp.Tool{
|
||||||
|
Name: "env_versions",
|
||||||
|
Description: "Все версии конкретного env-файла сервиса. Read-only.",
|
||||||
|
InputSchema: schema(map[string]any{
|
||||||
|
"server": serverProp(),
|
||||||
|
"app_id": intProps("Идентификатор сервиса", true),
|
||||||
|
"filename": strProps("Имя env-файла, например .env", true),
|
||||||
|
}, []string{"app_id", "filename"}),
|
||||||
|
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
args := requestArgs(req)
|
||||||
|
id, res := appIDArg(args)
|
||||||
|
if res != nil {
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
filename, err := requireString(args, "filename")
|
||||||
|
if err != nil {
|
||||||
|
return errorResult(err.Error()), nil
|
||||||
|
}
|
||||||
|
return observeCall(ctx, req, func(api *configserver.API) (any, error) {
|
||||||
|
return api.EnvVersions(ctx, id, filename)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func registerTaskTools(s *mcp.Server) {
|
||||||
|
addTool(s, &mcp.Tool{
|
||||||
|
Name: "list_tasks",
|
||||||
|
Description: "Список задач агентов с фильтрами (аудит, поиск failed/pending). Read-only.",
|
||||||
|
InputSchema: schema(map[string]any{
|
||||||
|
"server": serverProp(),
|
||||||
|
"host_ip": strProps("Фильтр по IP хоста", false),
|
||||||
|
"status": strProps("Фильтр по статусу", false, "pending", "in_progress", "completed", "failed"),
|
||||||
|
"task_type": strProps("Фильтр по типу задачи (deploy, restart, down, migration, update_config, release_sync, release_start, update_front)", false),
|
||||||
|
"limit": intProps("Максимум записей (1..500, по умолчанию 100)", false),
|
||||||
|
}, nil),
|
||||||
|
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
args := requestArgs(req)
|
||||||
|
filter := configserver.TaskFilter{
|
||||||
|
HostIP: getString(args, "host_ip", ""),
|
||||||
|
Status: getString(args, "status", ""),
|
||||||
|
TaskType: getString(args, "task_type", ""),
|
||||||
|
Limit: getInt(args, "limit", 100),
|
||||||
|
}
|
||||||
|
return observeCall(ctx, req, func(api *configserver.API) (any, error) {
|
||||||
|
return api.ListTasks(ctx, filter)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
addTool(s, &mcp.Tool{
|
||||||
|
Name: "get_task",
|
||||||
|
Description: "Статус и вывод задачи. wait=true — дождаться завершения (ограничено task_poll_max_sec); иначе вернуть текущее состояние. Read-only.",
|
||||||
|
InputSchema: schema(map[string]any{
|
||||||
|
"server": serverProp(),
|
||||||
|
"task_id": strProps("UUID задачи", true),
|
||||||
|
"wait": boolProps("Дождаться завершения (по умолчанию false)", false),
|
||||||
|
"tail": boolProps("Оставить хвост вывода при обрезке (по умолчанию true — ошибки обычно в конце)", false),
|
||||||
|
"output_bytes": intProps("Лимит вывода в байтах (по умолчанию — max_output_bytes сервера)", false),
|
||||||
|
}, []string{"task_id"}),
|
||||||
|
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
args := requestArgs(req)
|
||||||
|
taskID, err := requireString(args, "task_id")
|
||||||
|
if err != nil {
|
||||||
|
return errorResult(err.Error()), nil
|
||||||
|
}
|
||||||
|
wait := getBool(args, "wait", false)
|
||||||
|
tail := getBool(args, "tail", true)
|
||||||
|
limit := getInt(args, "output_bytes", 0)
|
||||||
|
|
||||||
|
return observeCall(ctx, req, func(api *configserver.API) (any, error) {
|
||||||
|
var task *configserver.Task
|
||||||
|
if wait {
|
||||||
|
task, err = api.WaitTask(ctx, taskID)
|
||||||
|
} else {
|
||||||
|
task, err = api.GetTask(ctx, taskID)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = api.Srv.MaxOutputBytes
|
||||||
|
}
|
||||||
|
out := *task
|
||||||
|
out.Output, _ = truncateText(out.Output, limit, tail)
|
||||||
|
return out, nil
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func registerDeploymentReadTools(s *mcp.Server) {
|
||||||
|
addTool(s, &mcp.Tool{
|
||||||
|
Name: "get_deployment_files",
|
||||||
|
Description: "Файлы развёртывания: schema, manifest, prebuild_vars, ip_overrides. Read-only.",
|
||||||
|
InputSchema: schema(map[string]any{"server": serverProp()}, nil),
|
||||||
|
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
return observeCall(ctx, req, func(api *configserver.API) (any, error) {
|
||||||
|
return api.DeploymentFiles(ctx)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
addTool(s, &mcp.Tool{
|
||||||
|
Name: "ip_match",
|
||||||
|
Description: "Диагностика сопоставления IP из schema.json с IP агентов в БД. Read-only.",
|
||||||
|
InputSchema: schema(map[string]any{"server": serverProp()}, nil),
|
||||||
|
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
return observeCall(ctx, req, func(api *configserver.API) (any, error) {
|
||||||
|
return api.IPMatch(ctx)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
addTool(s, &mcp.Tool{
|
||||||
|
Name: "release_job",
|
||||||
|
Description: "Статус фоновой загрузки релиза (status: idle/running/completed/failed, фаза, прогресс образов). Read-only.",
|
||||||
|
InputSchema: schema(map[string]any{"server": serverProp()}, nil),
|
||||||
|
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
return observeCall(ctx, req, func(api *configserver.API) (any, error) {
|
||||||
|
return api.ReleaseJob(ctx)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
addTool(s, &mcp.Tool{
|
||||||
|
Name: "deployment_tasks",
|
||||||
|
Description: "Последние release-задачи (sync/start). Read-only.",
|
||||||
|
InputSchema: schema(map[string]any{"server": serverProp()}, nil),
|
||||||
|
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
return observeCall(ctx, req, func(api *configserver.API) (any, error) {
|
||||||
|
return api.DeploymentTasks(ctx)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func registerSettingsTools(s *mcp.Server) {
|
||||||
|
addTool(s, &mcp.Tool{
|
||||||
|
Name: "stats",
|
||||||
|
Description: "Статистика: количество версий compose/env и логов миграций. Read-only.",
|
||||||
|
InputSchema: schema(map[string]any{"server": serverProp()}, nil),
|
||||||
|
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
return observeCall(ctx, req, func(api *configserver.API) (any, error) {
|
||||||
|
return api.Stats(ctx)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
addTool(s, &mcp.Tool{
|
||||||
|
Name: "service_map",
|
||||||
|
Description: "Карта хостов и сервисов. Read-only.",
|
||||||
|
InputSchema: schema(map[string]any{"server": serverProp()}, nil),
|
||||||
|
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
return observeCall(ctx, req, func(api *configserver.API) (any, error) {
|
||||||
|
return api.ServiceMap(ctx)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
package tools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"omnichannel-mcp/internal/configserver"
|
||||||
|
|
||||||
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||||
|
)
|
||||||
|
|
||||||
|
// operate.go — жизненный цикл сервиса (deploy/restart/down/migrate) и загрузка
|
||||||
|
// фронта. Мутации; обычно require_approval в agent.yaml.
|
||||||
|
|
||||||
|
func registerOperateTools(s *mcp.Server) {
|
||||||
|
addPatternTool(s, &mcp.Tool{
|
||||||
|
Name: "deploy",
|
||||||
|
Description: "Поднять сервис (docker-compose up -d). Создаёт задачу агенту; wait=true — дождаться результата.",
|
||||||
|
InputSchema: lifecycleSchema(),
|
||||||
|
}, appPatterns, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
return runLifecycle(ctx, req, "deploy", false)
|
||||||
|
})
|
||||||
|
|
||||||
|
addPatternTool(s, &mcp.Tool{
|
||||||
|
Name: "restart",
|
||||||
|
Description: "Перезапустить сервис (down + up -d). Создаёт задачу агенту; wait=true — дождаться результата.",
|
||||||
|
InputSchema: lifecycleSchema(),
|
||||||
|
}, appPatterns, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
return runLifecycle(ctx, req, "restart", false)
|
||||||
|
})
|
||||||
|
|
||||||
|
addPatternTool(s, &mcp.Tool{
|
||||||
|
Name: "migrate",
|
||||||
|
Description: "Запустить миграции сервиса (docker-compose run migration). Создаёт задачу агенту.",
|
||||||
|
InputSchema: lifecycleSchema(),
|
||||||
|
}, appPatterns, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
return runLifecycle(ctx, req, "migrate", false)
|
||||||
|
})
|
||||||
|
|
||||||
|
addPatternTool(s, &mcp.Tool{
|
||||||
|
Name: "down",
|
||||||
|
Description: "Остановить сервис (docker-compose down). Разрушительно: требует confirm=\"true\".",
|
||||||
|
InputSchema: schema(map[string]any{
|
||||||
|
"server": serverProp(),
|
||||||
|
"app_id": intProps("Идентификатор сервиса", true),
|
||||||
|
"wait": boolProps("Дождаться завершения (по умолчанию false)", false),
|
||||||
|
"confirm": boolProps("Подтверждение разрушительной операции (обязательно true)", true),
|
||||||
|
}, []string{"app_id", "confirm"}),
|
||||||
|
}, appPatterns, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
return runLifecycle(ctx, req, "down", true)
|
||||||
|
})
|
||||||
|
|
||||||
|
addPatternTool(s, &mcp.Tool{
|
||||||
|
Name: "update_front",
|
||||||
|
Description: "Загрузить локальную сборку фронта на хост сервиса (полная замена build_folder). Разрушительно: требует confirm=\"true\". Каталог — внутри front_roots.",
|
||||||
|
InputSchema: schema(map[string]any{
|
||||||
|
"server": serverProp(),
|
||||||
|
"app_id": intProps("Идентификатор фронт-сервиса", true),
|
||||||
|
"build_dir": strProps("Локальный каталог сборки (абсолютный путь внутри front_roots)", true),
|
||||||
|
"confirm": boolProps("Подтверждение разрушительной операции (обязательно true)", true),
|
||||||
|
}, []string{"app_id", "build_dir", "confirm"}),
|
||||||
|
}, appPatterns, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
args := requestArgs(req)
|
||||||
|
if res := confirmGate(args, "update_front"); res != nil {
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
id, res := appIDArg(args)
|
||||||
|
if res != nil {
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
buildDir, err := requireString(args, "build_dir")
|
||||||
|
if err != nil {
|
||||||
|
return errorResult(err.Error()), nil
|
||||||
|
}
|
||||||
|
api, res := openAPI(ctx, args, true)
|
||||||
|
if res != nil {
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
defer api.Close()
|
||||||
|
return result(api.UpdateFront(ctx, id, buildDir))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// lifecycleSchema — общая схема deploy/restart/migrate/down.
|
||||||
|
func lifecycleSchema() map[string]any {
|
||||||
|
return schema(map[string]any{
|
||||||
|
"server": serverProp(),
|
||||||
|
"app_id": intProps("Идентификатор сервиса", true),
|
||||||
|
"wait": boolProps("Дождаться завершения задачи (по умолчанию false)", false),
|
||||||
|
}, []string{"app_id"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// runLifecycle — общий путь операций жизненного цикла: вызвать действие, при
|
||||||
|
// wait=true ограниченно дождаться задачи агента.
|
||||||
|
func runLifecycle(ctx context.Context, req *mcp.CallToolRequest, action string, needConfirm bool) (*mcp.CallToolResult, error) {
|
||||||
|
args := requestArgs(req)
|
||||||
|
if needConfirm {
|
||||||
|
if res := confirmGate(args, action); res != nil {
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
id, res := appIDArg(args)
|
||||||
|
if res != nil {
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
wait := getBool(args, "wait", false)
|
||||||
|
|
||||||
|
api, res := openAPI(ctx, args, true)
|
||||||
|
if res != nil {
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
defer api.Close()
|
||||||
|
|
||||||
|
var (
|
||||||
|
ack *configserver.TaskAck
|
||||||
|
err error
|
||||||
|
)
|
||||||
|
switch action {
|
||||||
|
case "deploy":
|
||||||
|
ack, err = api.Deploy(ctx, id)
|
||||||
|
case "restart":
|
||||||
|
ack, err = api.Restart(ctx, id)
|
||||||
|
case "down":
|
||||||
|
ack, err = api.Down(ctx, id)
|
||||||
|
case "migrate":
|
||||||
|
ack, err = api.Migrate(ctx, id)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return result(nil, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Без wait отдаём task_id, чтобы не блокировать последовательный loop агента.
|
||||||
|
if !wait {
|
||||||
|
return result(ack, nil)
|
||||||
|
}
|
||||||
|
task, err := api.WaitTask(ctx, ack.TaskID)
|
||||||
|
if err != nil {
|
||||||
|
return result(nil, err)
|
||||||
|
}
|
||||||
|
return result(map[string]any{"ack": ack, "task": task}, nil)
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
package tools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"omnichannel-mcp/internal/configserver"
|
||||||
|
|
||||||
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||||
|
)
|
||||||
|
|
||||||
|
// overview.go — сводные read-only инструменты для эксплуатации. Они не
|
||||||
|
// изменяют состояние, а собирают картину несколькими GET-вызовами (best-effort:
|
||||||
|
// сбой одного источника не рушит остальные, а попадает в поле errors).
|
||||||
|
|
||||||
|
func registerOverviewTools(s *mcp.Server) {
|
||||||
|
addTool(s, &mcp.Tool{
|
||||||
|
Name: "overview",
|
||||||
|
Description: "Сводка состояния стенда одним вызовом: health, статистика, карта сервисов, приложения не в OK, последние failed-задачи. Read-only.",
|
||||||
|
InputSchema: schema(map[string]any{"server": serverProp()}, nil),
|
||||||
|
}, handleOverview)
|
||||||
|
|
||||||
|
addTool(s, &mcp.Tool{
|
||||||
|
Name: "release_status",
|
||||||
|
Description: "Состояние развёртывания релиза: release_job, release-задачи, приложения, сопоставление IP. Read-only.",
|
||||||
|
InputSchema: schema(map[string]any{"server": serverProp()}, nil),
|
||||||
|
}, handleReleaseStatus)
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleOverview(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
return observeCall(ctx, req, func(api *configserver.API) (any, error) {
|
||||||
|
out := map[string]any{"server": api.Alias(), "read_only": api.Config.ReadOnly}
|
||||||
|
var errs []string
|
||||||
|
|
||||||
|
if status, body, err := api.Health(ctx); err != nil {
|
||||||
|
errs = append(errs, "health: "+err.Error())
|
||||||
|
} else {
|
||||||
|
out["health"] = map[string]any{"http_status": status, "body": body}
|
||||||
|
}
|
||||||
|
if st, err := api.Stats(ctx); err != nil {
|
||||||
|
errs = append(errs, "stats: "+err.Error())
|
||||||
|
} else {
|
||||||
|
out["stats"] = st
|
||||||
|
}
|
||||||
|
if sm, err := api.ServiceMap(ctx); err != nil {
|
||||||
|
errs = append(errs, "service_map: "+err.Error())
|
||||||
|
} else {
|
||||||
|
out["service_map"] = sm
|
||||||
|
}
|
||||||
|
if apps, err := api.ListApplications(ctx, false); err != nil {
|
||||||
|
errs = append(errs, "applications: "+err.Error())
|
||||||
|
} else {
|
||||||
|
out["unhealthy_applications"] = unhealthy(apps)
|
||||||
|
}
|
||||||
|
if tasks, err := api.ListTasks(ctx, configserver.TaskFilter{Status: "failed", Limit: 10}); err != nil {
|
||||||
|
errs = append(errs, "failed_tasks: "+err.Error())
|
||||||
|
} else {
|
||||||
|
out["recent_failed_tasks"] = tasks
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(errs) > 0 {
|
||||||
|
out["errors"] = errs
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleReleaseStatus(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
return observeCall(ctx, req, func(api *configserver.API) (any, error) {
|
||||||
|
out := map[string]any{"server": api.Alias()}
|
||||||
|
var errs []string
|
||||||
|
|
||||||
|
if job, err := api.ReleaseJob(ctx); err != nil {
|
||||||
|
errs = append(errs, "release_job: "+err.Error())
|
||||||
|
} else {
|
||||||
|
out["release_job"] = job
|
||||||
|
}
|
||||||
|
if tasks, err := api.DeploymentTasks(ctx); err != nil {
|
||||||
|
errs = append(errs, "deployment_tasks: "+err.Error())
|
||||||
|
} else {
|
||||||
|
out["deployment_tasks"] = tasks
|
||||||
|
}
|
||||||
|
if apps, err := api.ListApplications(ctx, false); err != nil {
|
||||||
|
errs = append(errs, "applications: "+err.Error())
|
||||||
|
} else {
|
||||||
|
out["applications"] = apps
|
||||||
|
}
|
||||||
|
if ipm, err := api.IPMatch(ctx); err != nil {
|
||||||
|
errs = append(errs, "ip_match: "+err.Error())
|
||||||
|
} else {
|
||||||
|
out["ip_match"] = ipm
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(errs) > 0 {
|
||||||
|
out["errors"] = errs
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// unhealthy возвращает приложения со статусом, отличным от OK.
|
||||||
|
func unhealthy(apps []configserver.Application) []configserver.Application {
|
||||||
|
out := []configserver.Application{}
|
||||||
|
for _, app := range apps {
|
||||||
|
if app.Status != "OK" {
|
||||||
|
out = append(out, app)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
package tools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// patterns.go — доменные эмиттеры probe-паттернов разрешений для omnichannel-mcp.
|
||||||
|
//
|
||||||
|
// Канонический паттерн несёт алиас сервера и ресурс, чтобы мультисерверная
|
||||||
|
// политика оператора могла различать стенды:
|
||||||
|
//
|
||||||
|
// server=<alias> app=<id>
|
||||||
|
// server=<alias> app=<id> file=<filename>
|
||||||
|
// server=<alias> version=<id>
|
||||||
|
// server=<alias> release
|
||||||
|
//
|
||||||
|
// «always» не эмитим: каждая разрушительная операция должна получать отдельный
|
||||||
|
// ask/allow у оператора (как в proxmox-модуле).
|
||||||
|
|
||||||
|
// effectiveServer возвращает алиас сервера для паттерна: явный аргумент либо
|
||||||
|
// default из конфига. Без конфига — "default".
|
||||||
|
func effectiveServer(args map[string]any) string {
|
||||||
|
if s := getString(args, "server", ""); s != "" {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
if m := manager(); m != nil {
|
||||||
|
if cfg, _, err := m.Config(configPathArg(args)); err == nil && cfg.Default != "" {
|
||||||
|
return cfg.Default
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "default"
|
||||||
|
}
|
||||||
|
|
||||||
|
func serverPart(args map[string]any) string { return "server=" + effectiveServer(args) }
|
||||||
|
|
||||||
|
// appPatterns — паттерн ресурса-приложения (deploy/restart/down/migrate/...).
|
||||||
|
func appPatterns(args map[string]any) ([]string, []string) {
|
||||||
|
parts := []string{serverPart(args)}
|
||||||
|
if id := getInt(args, "app_id", 0); id > 0 {
|
||||||
|
parts = append(parts, "app="+strconv.Itoa(id))
|
||||||
|
}
|
||||||
|
return []string{strings.Join(parts, " ")}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// envPatterns — паттерн env-файла (set_env/restore_env_version).
|
||||||
|
func envPatterns(args map[string]any) ([]string, []string) {
|
||||||
|
parts := []string{serverPart(args)}
|
||||||
|
if id := getInt(args, "app_id", 0); id > 0 {
|
||||||
|
parts = append(parts, "app="+strconv.Itoa(id))
|
||||||
|
}
|
||||||
|
if fn := getString(args, "filename", ""); fn != "" {
|
||||||
|
parts = append(parts, "file="+fn)
|
||||||
|
}
|
||||||
|
return []string{strings.Join(parts, " ")}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// versionPatterns — паттерн версии конфигурации (restore_compose).
|
||||||
|
func versionPatterns(args map[string]any) ([]string, []string) {
|
||||||
|
parts := []string{serverPart(args)}
|
||||||
|
if id := getInt(args, "version_id", 0); id > 0 {
|
||||||
|
parts = append(parts, "version="+strconv.Itoa(id))
|
||||||
|
}
|
||||||
|
return []string{strings.Join(parts, " ")}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// releasePatterns — паттерн операций релиза/развёртывания.
|
||||||
|
func releasePatterns(args map[string]any) ([]string, []string) {
|
||||||
|
return []string{serverPart(args) + " release"}, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
// Package tools реализует MCP-инструменты модуля omnichannel-mcp поверх
|
||||||
|
// доменного пакета configserver. Слой тонкий: разобрать аргументы, вызвать
|
||||||
|
// домен, вернуть текст/ошибку. Бизнес-логики здесь нет.
|
||||||
|
package tools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"omnichannel-mcp/internal/configserver"
|
||||||
|
|
||||||
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||||
|
)
|
||||||
|
|
||||||
|
// mgr — одиночный Manager процесса (см. ARCHITECTURE.md §2). Устанавливается
|
||||||
|
// один раз в RegisterAll; Manager потокобезопасен.
|
||||||
|
var mgr *configserver.Manager
|
||||||
|
|
||||||
|
func manager() *configserver.Manager { return mgr }
|
||||||
|
|
||||||
|
// RegisterAll — единая точка регистрации инструментов, сгруппированных по
|
||||||
|
// фазам жизненного цикла: observe (чтение) → configure → operate → release →
|
||||||
|
// maintain. Имена инструментов — без префикса (префикс может добавить MCP-хост).
|
||||||
|
func RegisterAll(s *mcp.Server, m *configserver.Manager) {
|
||||||
|
mgr = m
|
||||||
|
registerObserveTools(s)
|
||||||
|
registerConfigureTools(s)
|
||||||
|
registerOperateTools(s)
|
||||||
|
registerReleaseTools(s)
|
||||||
|
registerMaintainTools(s)
|
||||||
|
registerOverviewTools(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
// openAPI резолвит конфиг/сервер и открывает фасад API. Любая ошибка на этом
|
||||||
|
// шаге (нет конфига, неизвестный алиас, read_only) — доменная: отдаём модели.
|
||||||
|
func openAPI(ctx context.Context, args map[string]any, write bool) (*configserver.API, *mcp.CallToolResult) {
|
||||||
|
m := manager()
|
||||||
|
if m == nil {
|
||||||
|
return nil, errorResult("omnichannel-mcp: manager не инициализирован")
|
||||||
|
}
|
||||||
|
alias := getString(args, "server", "")
|
||||||
|
api, err := m.Open(ctx, configPathArg(args), alias, write)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errorResult(err.Error())
|
||||||
|
}
|
||||||
|
return api, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// configPathArg возвращает опциональный путь к конфигу из аргументов вызова.
|
||||||
|
// Основной способ задания конфига — флаг `-config`; per-call переопределение
|
||||||
|
// (`config_path`) поддержано для многотенантных обёрток. `_tenant_config` —
|
||||||
|
// устаревший алиас, принимается для совместимости.
|
||||||
|
func configPathArg(args map[string]any) string {
|
||||||
|
if v := getString(args, "config_path", ""); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return getString(args, "_tenant_config", "")
|
||||||
|
}
|
||||||
|
|
||||||
|
// result приводит (значение, ошибка) домена к результату инструмента:
|
||||||
|
// доменные ошибки — recoverable (текст видит модель), инфраструктурные — Go-ошибка.
|
||||||
|
func result(v any, err error) (*mcp.CallToolResult, error) {
|
||||||
|
if err != nil {
|
||||||
|
if configserver.IsDomainError(err) {
|
||||||
|
return errorResult(err.Error()), nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return textResult(jsonText(v)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// confirmGate требует confirm="true" для разрушительных операций. Это второй
|
||||||
|
// слой поверх require_approval в agent.yaml.
|
||||||
|
func confirmGate(args map[string]any, what string) *mcp.CallToolResult {
|
||||||
|
if !getBool(args, "confirm", false) {
|
||||||
|
return errorResult("операция \"" + what + "\" требует confirm=\"true\"")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// serverProp — общее описание аргумента выбора сервера.
|
||||||
|
func serverProp() map[string]any {
|
||||||
|
return strProps("Алиас сервера из config.json (по умолчанию — default)", false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// appIDArg читает обязательный app_id.
|
||||||
|
func appIDArg(args map[string]any) (int, *mcp.CallToolResult) {
|
||||||
|
id := getInt(args, "app_id", 0)
|
||||||
|
if id <= 0 {
|
||||||
|
return 0, errorResult("нужен положительный app_id")
|
||||||
|
}
|
||||||
|
return id, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// versionIDArg читает обязательный version_id.
|
||||||
|
func versionIDArg(args map[string]any) (int, *mcp.CallToolResult) {
|
||||||
|
id := getInt(args, "version_id", 0)
|
||||||
|
if id <= 0 {
|
||||||
|
return 0, errorResult("нужен положительный version_id")
|
||||||
|
}
|
||||||
|
return id, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
package tools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"omnichannel-mcp/internal/configserver"
|
||||||
|
|
||||||
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||||
|
)
|
||||||
|
|
||||||
|
// release.go — страница «Развёртывание»: файлы схемы, seed/fill, релиз.
|
||||||
|
|
||||||
|
func registerReleaseTools(s *mcp.Server) {
|
||||||
|
addPatternTool(s, &mcp.Tool{
|
||||||
|
Name: "save_deployment_files",
|
||||||
|
Description: "Сохранить файлы развёртывания (schema/manifest/prebuild_vars/ip_overrides). Передавай только изменяемые поля.",
|
||||||
|
InputSchema: schema(map[string]any{
|
||||||
|
"server": serverProp(),
|
||||||
|
"schema": strProps("JSON-схема развёртывания (опционально)", false),
|
||||||
|
"manifest": strProps("JSON-манифест (опционально)", false),
|
||||||
|
"prebuild_vars": strProps("Строка prebuild_vars (опционально)", false),
|
||||||
|
"ip_overrides": strProps("JSON ip_overrides (опционально)", false),
|
||||||
|
}, nil),
|
||||||
|
}, releasePatterns, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
args := requestArgs(req)
|
||||||
|
upd := configserver.DeploymentUpdate{
|
||||||
|
Schema: optionalString(args, "schema"),
|
||||||
|
Manifest: optionalString(args, "manifest"),
|
||||||
|
PrebuildVars: optionalString(args, "prebuild_vars"),
|
||||||
|
IPOverrides: optionalString(args, "ip_overrides"),
|
||||||
|
}
|
||||||
|
if upd.Schema == nil && upd.Manifest == nil && upd.PrebuildVars == nil && upd.IPOverrides == nil {
|
||||||
|
return errorResult("нужно передать хотя бы одно из полей: schema, manifest, prebuild_vars, ip_overrides"), nil
|
||||||
|
}
|
||||||
|
api, res := openAPI(ctx, args, true)
|
||||||
|
if res != nil {
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
defer api.Close()
|
||||||
|
return result(api.SaveDeploymentFiles(ctx, upd))
|
||||||
|
})
|
||||||
|
|
||||||
|
addPatternTool(s, &mcp.Tool{
|
||||||
|
Name: "seed_hosts",
|
||||||
|
Description: "Создать служебные записи хостов по IP из schema.json. Изменяет БД.",
|
||||||
|
InputSchema: schema(map[string]any{"server": serverProp()}, nil),
|
||||||
|
}, releasePatterns, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
args := requestArgs(req)
|
||||||
|
api, res := openAPI(ctx, args, true)
|
||||||
|
if res != nil {
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
defer api.Close()
|
||||||
|
return result(api.SeedHosts(ctx))
|
||||||
|
})
|
||||||
|
|
||||||
|
addPatternTool(s, &mcp.Tool{
|
||||||
|
Name: "fill_vars",
|
||||||
|
Description: "Заполнить prebuild_vars из schema.json. Изменяет файл конфигурации развёртывания.",
|
||||||
|
InputSchema: schema(map[string]any{"server": serverProp()}, nil),
|
||||||
|
}, releasePatterns, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
args := requestArgs(req)
|
||||||
|
api, res := openAPI(ctx, args, true)
|
||||||
|
if res != nil {
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
defer api.Close()
|
||||||
|
v, err := api.FillVars(ctx)
|
||||||
|
return result(v, err)
|
||||||
|
})
|
||||||
|
|
||||||
|
addPatternTool(s, &mcp.Tool{
|
||||||
|
Name: "download_release",
|
||||||
|
Description: "Загрузить релиз. load_images=false — синхронно (пакеты + sync-задачи); true — фоновая загрузка образов (прогресс через release_job). Разрушительно/тяжело: требует confirm=\"true\".",
|
||||||
|
InputSchema: schema(map[string]any{
|
||||||
|
"server": serverProp(),
|
||||||
|
"load_images": boolProps("Загружать docker-образы в фоне (по умолчанию false)", false),
|
||||||
|
"gitlab_token": strProps("GitLab-токен (опционально; иначе из конфига/env сервера)", false),
|
||||||
|
"confirm": boolProps("Подтверждение операции (обязательно true)", true),
|
||||||
|
}, []string{"confirm"}),
|
||||||
|
}, releasePatterns, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
args := requestArgs(req)
|
||||||
|
if res := confirmGate(args, "download_release"); res != nil {
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
api, res := openAPI(ctx, args, true)
|
||||||
|
if res != nil {
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
defer api.Close()
|
||||||
|
ack, err := api.DownloadRelease(ctx, getString(args, "gitlab_token", ""), getBool(args, "load_images", false))
|
||||||
|
return result(ack, err)
|
||||||
|
})
|
||||||
|
|
||||||
|
addPatternTool(s, &mcp.Tool{
|
||||||
|
Name: "start_services",
|
||||||
|
Description: "Создать задачи запуска сервисов для хостов из schema.json. Изменяет состояние стенда.",
|
||||||
|
InputSchema: schema(map[string]any{"server": serverProp()}, nil),
|
||||||
|
}, releasePatterns, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
args := requestArgs(req)
|
||||||
|
api, res := openAPI(ctx, args, true)
|
||||||
|
if res != nil {
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
defer api.Close()
|
||||||
|
return result(api.StartServices(ctx))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// optionalString возвращает указатель на строковый аргумент, если он задан
|
||||||
|
// (в том числе пустой строкой), иначе nil.
|
||||||
|
func optionalString(args map[string]any, key string) *string {
|
||||||
|
if _, ok := args[key]; !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
v := getString(args, key, "")
|
||||||
|
return &v
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package tools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
|
||||||
|
"git.totmin.ru/en2zmax/forge-toolkit"
|
||||||
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||||
|
)
|
||||||
|
|
||||||
|
// toolkit.go — тонкие адаптеры к общему тулкиту (модуль forge-toolkit).
|
||||||
|
// Локальные имена сохраняют хендлеры независимыми от пакета-источника.
|
||||||
|
|
||||||
|
func requestArgs(req *mcp.CallToolRequest) map[string]any { return toolkit.RequestArgs(req) }
|
||||||
|
|
||||||
|
func getString(args map[string]any, key, def string) string { return toolkit.GetString(args, key, def) }
|
||||||
|
|
||||||
|
func requireString(args map[string]any, key string) (string, error) {
|
||||||
|
return toolkit.RequireString(args, key)
|
||||||
|
}
|
||||||
|
|
||||||
|
func getInt(args map[string]any, key string, def int) int { return toolkit.GetInt(args, key, def) }
|
||||||
|
|
||||||
|
func getBool(args map[string]any, key string, def bool) bool { return toolkit.GetBool(args, key, def) }
|
||||||
|
|
||||||
|
func schema(properties map[string]any, required []string) map[string]any {
|
||||||
|
return toolkit.Schema(properties, required)
|
||||||
|
}
|
||||||
|
|
||||||
|
func strProps(desc string, required bool, enum ...string) map[string]any {
|
||||||
|
return toolkit.StrProps(desc, required, enum...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func intProps(desc string, required bool) map[string]any { return toolkit.IntProps(desc, required) }
|
||||||
|
|
||||||
|
func boolProps(desc string, required bool) map[string]any { return toolkit.BoolProps(desc, required) }
|
||||||
|
|
||||||
|
func textResult(text string) *mcp.CallToolResult { return toolkit.Text(text) }
|
||||||
|
|
||||||
|
func errorResult(msg string) *mcp.CallToolResult { return toolkit.Error(msg) }
|
||||||
|
|
||||||
|
// addTool регистрирует инструмент без probe-паттернов (read-only).
|
||||||
|
func addTool(s *mcp.Server, tool *mcp.Tool, h mcp.ToolHandler) { s.AddTool(tool, h) }
|
||||||
|
|
||||||
|
// patternsFn — эмиттер permission-паттернов разрешений для probe-запросов.
|
||||||
|
type patternsFn = toolkit.PatternsFn
|
||||||
|
|
||||||
|
// addPatternTool регистрирует изменяющий инструмент с поддержкой probe: хост
|
||||||
|
// получает канонический паттерн ресурса, не выполняя действие.
|
||||||
|
func addPatternTool(s *mcp.Server, tool *mcp.Tool, fn patternsFn, h mcp.ToolHandler) {
|
||||||
|
toolkit.RegisterPatternTool(s, tool, fn, h)
|
||||||
|
}
|
||||||
|
|
||||||
|
// jsonText сериализует значение в читаемый JSON для ответа модели.
|
||||||
|
func jsonText(v any) string {
|
||||||
|
b, err := json.MarshalIndent(v, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return "{}"
|
||||||
|
}
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package tools
|
||||||
|
|
||||||
|
import "strconv"
|
||||||
|
|
||||||
|
// util.go — небольшие помощники MCP-слоя.
|
||||||
|
|
||||||
|
// truncateText ограничивает длинный вывод (задачи, compose, env), чтобы он не
|
||||||
|
// съел контекст модели. По умолчанию оставляем хвост: ошибки сборки/деплоя
|
||||||
|
// обычно в конце вывода. Второй результат — был ли вывод обрезан.
|
||||||
|
func truncateText(s string, maxBytes int, tail bool) (string, bool) {
|
||||||
|
if maxBytes <= 0 || len(s) <= maxBytes {
|
||||||
|
return s, false
|
||||||
|
}
|
||||||
|
cut := len(s) - maxBytes
|
||||||
|
if tail {
|
||||||
|
return "…(обрезано " + strconv.Itoa(cut) + " байт, показан хвост)\n" + s[len(s)-maxBytes:], true
|
||||||
|
}
|
||||||
|
return s[:maxBytes] + "\n…(обрезано " + strconv.Itoa(cut) + " байт, показано начало)", true
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
// Command omnichannel-mcp — MCP-сервер управления платформой Omnichannel через
|
||||||
|
// HTTP API config_server v1.1.0+ (деплой и эксплуатация).
|
||||||
|
//
|
||||||
|
// Следует общим соглашениям MCP-серверов: stdio-only, один потокобезопасный
|
||||||
|
// Manager, ручные JSON-схемы, отдельный домен, конфиг с live-reload,
|
||||||
|
// probe-паттерны разрешений на изменяющих инструментах.
|
||||||
|
//
|
||||||
|
// Логи пишутся ТОЛЬКО в stderr: stdout занят JSON-RPC протоколом.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"omnichannel-mcp/internal/configserver"
|
||||||
|
"omnichannel-mcp/internal/tools"
|
||||||
|
|
||||||
|
"git.totmin.ru/en2zmax/forge-toolkit"
|
||||||
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||||
|
)
|
||||||
|
|
||||||
|
// serverName — имя MCP-сервера; часть хостов добавляет префикс serverName+"__"
|
||||||
|
// к именам инструментов.
|
||||||
|
const serverName = "omnichannel-mcp"
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
// --health должен быть дешёвым: без чтения конфига и подключений.
|
||||||
|
if toolkit.Health() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
configPath := flag.String("config", "", "путь к config.json (иначе ищется OMNI_CONFIG_DIR/omnichannel-mcp.json)")
|
||||||
|
checkConfig := flag.Bool("check-config", false, "проверить конфиг, напечатать (секреты маскируются) и выйти")
|
||||||
|
logLevel := flag.String("log-level", envOr("OMNI_LOG_LEVEL", "info"), "уровень логов: debug|info|warn|error")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
setupLogger(*logLevel)
|
||||||
|
|
||||||
|
if *checkConfig {
|
||||||
|
os.Exit(runCheckConfig(*configPath))
|
||||||
|
}
|
||||||
|
|
||||||
|
mgr := configserver.NewManager(*configPath)
|
||||||
|
defer mgr.Close()
|
||||||
|
|
||||||
|
if err := toolkit.Run(serverName, func(s *mcp.Server) {
|
||||||
|
tools.RegisterAll(s, mgr)
|
||||||
|
}); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "%s: %v\n", serverName, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// runCheckConfig печатает разрешённый конфиг (с маской секретов) — для проверки
|
||||||
|
// настройки до подключения клиента и в CI. Возвращает код выхода.
|
||||||
|
func runCheckConfig(cliPath string) int {
|
||||||
|
mgr := configserver.NewManager(cliPath)
|
||||||
|
path := mgr.ResolvePath("")
|
||||||
|
if path == "" {
|
||||||
|
fmt.Fprintln(os.Stderr, "config: путь не задан (нужен -config или OMNI_CONFIG_DIR)")
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
cfg, _, err := mgr.Config("")
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "config: %v\n", err)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
out, err := json.MarshalIndent(cfg.Redacted(), "", " ")
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "config: %v\n", err)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
fmt.Printf("config: %s\n%s\n", path, out)
|
||||||
|
fmt.Printf("servers: %s (default: %s)\n", strings.Join(cfg.ServerNames(), ", "), cfg.Default)
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// setupLogger направляет логи в stderr с выбранным уровнем.
|
||||||
|
func setupLogger(level string) {
|
||||||
|
var lvl slog.Level
|
||||||
|
switch strings.ToLower(strings.TrimSpace(level)) {
|
||||||
|
case "debug":
|
||||||
|
lvl = slog.LevelDebug
|
||||||
|
case "warn", "warning":
|
||||||
|
lvl = slog.LevelWarn
|
||||||
|
case "error":
|
||||||
|
lvl = slog.LevelError
|
||||||
|
default:
|
||||||
|
lvl = slog.LevelInfo
|
||||||
|
}
|
||||||
|
handler := slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: lvl})
|
||||||
|
slog.SetDefault(slog.New(handler))
|
||||||
|
}
|
||||||
|
|
||||||
|
func envOr(key, def string) string {
|
||||||
|
if v := os.Getenv(key); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return def
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user