From fdbd4a4fb66d28805e04a3635abbcddbf41fc2e0 Mon Sep 17 00:00:00 2001 From: Maksim Totmin Date: Wed, 7 Oct 2026 20:13:23 +0700 Subject: [PATCH] =?UTF-8?q?Initial=20commit:=20omnichannel-mcp=20=E2=80=94?= =?UTF-8?q?=20MCP-=D1=81=D0=B5=D1=80=D0=B2=D0=B5=D1=80=20=D1=83=D0=BF?= =?UTF-8?q?=D1=80=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=BF=D0=BB?= =?UTF-8?q?=D0=B0=D1=82=D1=84=D0=BE=D1=80=D0=BC=D0=BE=D0=B9=20Omnichannel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 16 + .gitea/workflows/ci.yml | 53 +++ .gitignore | 8 + CHANGELOG.md | 23 ++ CONTRIBUTING.md | 47 +++ LICENSE | 202 +++++++++++ Makefile | 49 +++ README.md | 126 +++++++ config.json | 14 + docs/api-compatibility.md | 47 +++ docs/architecture.md | 74 ++++ docs/configuration.md | 153 +++++++++ docs/deployment.md | 132 ++++++++ docs/getting-started.md | 109 ++++++ docs/mcp-clients.md | 67 ++++ docs/operations.md | 82 +++++ docs/security.md | 91 +++++ docs/tools.md | 80 +++++ examples/config.multi-server.json | 22 ++ examples/config.readonly-observer.json | 12 + examples/config.single-server.json | 13 + examples/demo/README.md | 46 +++ examples/demo/config.demo.json | 13 + examples/demo/demo.py | 159 +++++++++ examples/demo/docker-compose.yml | 55 +++ examples/demo/e2e.py | 189 +++++++++++ examples/demo/init-db.sh | 272 +++++++++++++++ examples/prompts.md | 56 ++++ go.mod | 19 ++ go.sum | 26 ++ internal/configserver/api_configure.go | 67 ++++ internal/configserver/api_maintain.go | 18 + internal/configserver/api_observe.go | 289 ++++++++++++++++ internal/configserver/api_operate.go | 149 +++++++++ internal/configserver/api_operate_test.go | 81 +++++ internal/configserver/api_release.go | 97 ++++++ internal/configserver/config.go | 391 ++++++++++++++++++++++ internal/configserver/config_test.go | 110 ++++++ internal/configserver/errors.go | 46 +++ internal/configserver/manager.go | 209 ++++++++++++ internal/configserver/models.go | 216 ++++++++++++ internal/configserver/session.go | 354 ++++++++++++++++++++ internal/configserver/session_test.go | 153 +++++++++ internal/tools/configure.go | 140 ++++++++ internal/tools/maintain.go | 38 +++ internal/tools/observe.go | 338 +++++++++++++++++++ internal/tools/operate.go | 140 ++++++++ internal/tools/overview.go | 109 ++++++ internal/tools/patterns.go | 70 ++++ internal/tools/registry.go | 101 ++++++ internal/tools/release.go | 118 +++++++ internal/tools/toolkit.go | 60 ++++ internal/tools/util.go | 19 ++ main.go | 104 ++++++ 54 files changed, 5672 insertions(+) create mode 100644 .env.example create mode 100644 .gitea/workflows/ci.yml create mode 100644 .gitignore create mode 100644 CHANGELOG.md create mode 100644 CONTRIBUTING.md create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 README.md create mode 100644 config.json create mode 100644 docs/api-compatibility.md create mode 100644 docs/architecture.md create mode 100644 docs/configuration.md create mode 100644 docs/deployment.md create mode 100644 docs/getting-started.md create mode 100644 docs/mcp-clients.md create mode 100644 docs/operations.md create mode 100644 docs/security.md create mode 100644 docs/tools.md create mode 100644 examples/config.multi-server.json create mode 100644 examples/config.readonly-observer.json create mode 100644 examples/config.single-server.json create mode 100644 examples/demo/README.md create mode 100644 examples/demo/config.demo.json create mode 100644 examples/demo/demo.py create mode 100644 examples/demo/docker-compose.yml create mode 100644 examples/demo/e2e.py create mode 100644 examples/demo/init-db.sh create mode 100644 examples/prompts.md create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/configserver/api_configure.go create mode 100644 internal/configserver/api_maintain.go create mode 100644 internal/configserver/api_observe.go create mode 100644 internal/configserver/api_operate.go create mode 100644 internal/configserver/api_operate_test.go create mode 100644 internal/configserver/api_release.go create mode 100644 internal/configserver/config.go create mode 100644 internal/configserver/config_test.go create mode 100644 internal/configserver/errors.go create mode 100644 internal/configserver/manager.go create mode 100644 internal/configserver/models.go create mode 100644 internal/configserver/session.go create mode 100644 internal/configserver/session_test.go create mode 100644 internal/tools/configure.go create mode 100644 internal/tools/maintain.go create mode 100644 internal/tools/observe.go create mode 100644 internal/tools/operate.go create mode 100644 internal/tools/overview.go create mode 100644 internal/tools/patterns.go create mode 100644 internal/tools/registry.go create mode 100644 internal/tools/release.go create mode 100644 internal/tools/toolkit.go create mode 100644 internal/tools/util.go create mode 100644 main.go 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 +}