commit fdbd4a4fb66d28805e04a3635abbcddbf41fc2e0 Author: Maksim Totmin Date: Wed Oct 7 20:13:23 2026 +0700 Initial commit: omnichannel-mcp — MCP-сервер управления платформой Omnichannel diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..8c1f5fb --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..3ae1edc --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cb55aa0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +# Секреты и локальные данные — не коммитим. +config.local.json +*.local.json +.env + +# Бинарник модуля и кэш Python. +/omnichannel-mcp +__pycache__/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..06d32fe --- /dev/null +++ b/CHANGELOG.md @@ -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/`), примеры конфигураций и промптов, воспроизводимый + демо-стенд. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..ab8108e --- /dev/null +++ b/CONTRIBUTING.md @@ -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`. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..6c69701 --- /dev/null +++ b/Makefile @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..464b66e --- /dev/null +++ b/README.md @@ -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). Внутренний проект компании. diff --git a/config.json b/config.json new file mode 100644 index 0000000..5b0b634 --- /dev/null +++ b/config.json @@ -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": [] +} diff --git a/docs/api-compatibility.md b/docs/api-compatibility.md new file mode 100644 index 0000000..f2a8a6a --- /dev/null +++ b/docs/api-compatibility.md @@ -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/` | Детализация сервиса в JSON | +| `POST /api/compose/` | Установка compose через JSON | +| `POST /api/env//` | Установка env через JSON | +| `GET /api/tasks`, `GET /api/task/` | Список/статус задач | + +В более старых версиях (≤ 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`-конфигом. diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..1b8fbca --- /dev/null +++ b/docs/architecture.md @@ -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` — источник данных о +задачах и статусах. diff --git a/docs/configuration.md b/docs/configuration.md new file mode 100644 index 0000000..533d204 --- /dev/null +++ b/docs/configuration.md @@ -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. diff --git a/docs/deployment.md b/docs/deployment.md new file mode 100644 index 0000000..0df94a9 --- /dev/null +++ b/docs/deployment.md @@ -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`). diff --git a/docs/getting-started.md b/docs/getting-started.md new file mode 100644 index 0000000..a88c971 --- /dev/null +++ b/docs/getting-started.md @@ -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://:5005`. Проверить: `curl http://: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 +``` diff --git a/docs/mcp-clients.md b/docs/mcp-clients.md new file mode 100644 index 0000000..d02a98c --- /dev/null +++ b/docs/mcp-clients.md @@ -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`. diff --git a/docs/operations.md b/docs/operations.md new file mode 100644 index 0000000..7a02c44 --- /dev/null +++ b/docs/operations.md @@ -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` не наложатся друг на друга. diff --git a/docs/security.md b/docs/security.md new file mode 100644 index 0000000..b5b09fe --- /dev/null +++ b/docs/security.md @@ -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/`) — только с пользовательским 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. Об уязвимостях сообщайте в службу безопасности компании (не публикуйте в + общих трекерах). + diff --git a/docs/tools.md b/docs/tools.md new file mode 100644 index 0000000..5d0d253 --- /dev/null +++ b/docs/tools.md @@ -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`. diff --git a/examples/config.multi-server.json b/examples/config.multi-server.json new file mode 100644 index 0000000..d278dc0 --- /dev/null +++ b/examples/config.multi-server.json @@ -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"] +} diff --git a/examples/config.readonly-observer.json b/examples/config.readonly-observer.json new file mode 100644 index 0000000..a3680d4 --- /dev/null +++ b/examples/config.readonly-observer.json @@ -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 +} diff --git a/examples/config.single-server.json b/examples/config.single-server.json new file mode 100644 index 0000000..8f66aff --- /dev/null +++ b/examples/config.single-server.json @@ -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 +} diff --git a/examples/demo/README.md b/examples/demo/README.md new file mode 100644 index 0000000..77e58c8 --- /dev/null +++ b/examples/demo/README.md @@ -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 +``` diff --git a/examples/demo/config.demo.json b/examples/demo/config.demo.json new file mode 100644 index 0000000..b6a29e1 --- /dev/null +++ b/examples/demo/config.demo.json @@ -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"] +} diff --git a/examples/demo/demo.py b/examples/demo/demo.py new file mode 100644 index 0000000..a146893 --- /dev/null +++ b/examples/demo/demo.py @@ -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() diff --git a/examples/demo/docker-compose.yml b/examples/demo/docker-compose.yml new file mode 100644 index 0000000..23af3ca --- /dev/null +++ b/examples/demo/docker-compose.yml @@ -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: diff --git a/examples/demo/e2e.py b/examples/demo/e2e.py new file mode 100644 index 0000000..6292feb --- /dev/null +++ b/examples/demo/e2e.py @@ -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() diff --git a/examples/demo/init-db.sh b/examples/demo/init-db.sh new file mode 100644 index 0000000..2b2fce1 --- /dev/null +++ b/examples/demo/init-db.sh @@ -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!" \ No newline at end of file diff --git a/examples/prompts.md b/examples/prompts.md new file mode 100644 index 0000000..527bf58 --- /dev/null +++ b/examples/prompts.md @@ -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"` — агент обязан спросить +> подтверждение явно. diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..3f348db --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..577b34d --- /dev/null +++ b/go.sum @@ -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= diff --git a/internal/configserver/api_configure.go b/internal/configserver/api_configure.go new file mode 100644 index 0000000..ba7bcdc --- /dev/null +++ b/internal/configserver/api_configure.go @@ -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 +} diff --git a/internal/configserver/api_maintain.go b/internal/configserver/api_maintain.go new file mode 100644 index 0000000..b00b1bd --- /dev/null +++ b/internal/configserver/api_maintain.go @@ -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 +} diff --git a/internal/configserver/api_observe.go b/internal/configserver/api_observe.go new file mode 100644 index 0000000..60705c3 --- /dev/null +++ b/internal/configserver/api_observe.go @@ -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 +} diff --git a/internal/configserver/api_operate.go b/internal/configserver/api_operate.go new file mode 100644 index 0000000..9962271 --- /dev/null +++ b/internal/configserver/api_operate.go @@ -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//, возвращающий 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) +} diff --git a/internal/configserver/api_operate_test.go b/internal/configserver/api_operate_test.go new file mode 100644 index 0000000..e8f9312 --- /dev/null +++ b/internal/configserver/api_operate_test.go @@ -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(""), 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) + } +} diff --git a/internal/configserver/api_release.go b/internal/configserver/api_release.go new file mode 100644 index 0000000..351c0a3 --- /dev/null +++ b/internal/configserver/api_release.go @@ -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 +} diff --git a/internal/configserver/config.go b/internal/configserver/config.go new file mode 100644 index 0000000..1411606 --- /dev/null +++ b/internal/configserver/config.go @@ -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 загружает конфиг из основного файла, накладывая поверх соседний +// .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 возвращает путь /.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 +} diff --git a/internal/configserver/config_test.go b/internal/configserver/config_test.go new file mode 100644 index 0000000..d8660f5 --- /dev/null +++ b/internal/configserver/config_test.go @@ -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) + } +} diff --git a/internal/configserver/errors.go b/internal/configserver/errors.go new file mode 100644 index 0000000..04d5480 --- /dev/null +++ b/internal/configserver/errors.go @@ -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...)} +} diff --git a/internal/configserver/manager.go b/internal/configserver/manager.go new file mode 100644 index 0000000..dcb7ee6 --- /dev/null +++ b/internal/configserver/manager.go @@ -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) +} diff --git a/internal/configserver/models.go b/internal/configserver/models.go new file mode 100644 index 0000000..2f2b039 --- /dev/null +++ b/internal/configserver/models.go @@ -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/). +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/). +type RawConfig struct { + Compose string `json:"compose"` + Env string `json:"env"` +} + +// ComposeVersion — конкретная версия compose (GET /api/compose_version/). +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/). +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/, /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"` +} diff --git a/internal/configserver/session.go b/internal/configserver/session.go new file mode 100644 index 0000000..738302e --- /dev/null +++ b/internal/configserver/session.go @@ -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) +} diff --git a/internal/configserver/session_test.go b/internal/configserver/session_test.go new file mode 100644 index 0000000..ea60285 --- /dev/null +++ b/internal/configserver/session_test.go @@ -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) + } +} diff --git a/internal/tools/configure.go b/internal/tools/configure.go new file mode 100644 index 0000000..71c19f7 --- /dev/null +++ b/internal/tools/configure.go @@ -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)) + }) +} diff --git a/internal/tools/maintain.go b/internal/tools/maintain.go new file mode 100644 index 0000000..5017a6e --- /dev/null +++ b/internal/tools/maintain.go @@ -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)) + }) +} diff --git a/internal/tools/observe.go b/internal/tools/observe.go new file mode 100644 index 0000000..c1285b8 --- /dev/null +++ b/internal/tools/observe.go @@ -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) + }) + }) +} diff --git a/internal/tools/operate.go b/internal/tools/operate.go new file mode 100644 index 0000000..3a29ebf --- /dev/null +++ b/internal/tools/operate.go @@ -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) +} diff --git a/internal/tools/overview.go b/internal/tools/overview.go new file mode 100644 index 0000000..3111e3a --- /dev/null +++ b/internal/tools/overview.go @@ -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 +} diff --git a/internal/tools/patterns.go b/internal/tools/patterns.go new file mode 100644 index 0000000..3510143 --- /dev/null +++ b/internal/tools/patterns.go @@ -0,0 +1,70 @@ +package tools + +import ( + "strconv" + "strings" +) + +// patterns.go — доменные эмиттеры probe-паттернов разрешений для omnichannel-mcp. +// +// Канонический паттерн несёт алиас сервера и ресурс, чтобы мультисерверная +// политика оператора могла различать стенды: +// +// server= app= +// server= app= file= +// server= version= +// server= 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 +} diff --git a/internal/tools/registry.go b/internal/tools/registry.go new file mode 100644 index 0000000..e6cbcfc --- /dev/null +++ b/internal/tools/registry.go @@ -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 +} diff --git a/internal/tools/release.go b/internal/tools/release.go new file mode 100644 index 0000000..0276fdb --- /dev/null +++ b/internal/tools/release.go @@ -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 +} diff --git a/internal/tools/toolkit.go b/internal/tools/toolkit.go new file mode 100644 index 0000000..8db08eb --- /dev/null +++ b/internal/tools/toolkit.go @@ -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) +} diff --git a/internal/tools/util.go b/internal/tools/util.go new file mode 100644 index 0000000..2df6092 --- /dev/null +++ b/internal/tools/util.go @@ -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 +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..39b4f72 --- /dev/null +++ b/main.go @@ -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 +}