first commit
This commit is contained in:
@@ -0,0 +1,8 @@
|
|||||||
|
# Секреты и реальные подключения — никогда в git.
|
||||||
|
*.local.json
|
||||||
|
.env
|
||||||
|
*.key
|
||||||
|
id_ed25519*
|
||||||
|
|
||||||
|
# Собранный бинарь — артефакт сборки, не в репо.
|
||||||
|
/forge-tools-lexicomai
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
# forge-tools-lexicomai
|
||||||
|
|
||||||
|
MCP-сервер для **обслуживания продуктов Lexicom AI** на серверах компании.
|
||||||
|
Даёт AI-ассистенту (агенту) инструменты: посмотреть статус продукта, его
|
||||||
|
конфигурацию и лицензию, логи, здоровье, диагностику и список доступных
|
||||||
|
версий в репозитории. Позволяет администратору быстро разбираться с
|
||||||
|
развёрнутыми сервисами и сокращать рутину.
|
||||||
|
|
||||||
|
> **Что такое MCP-сервер коротко.** MCP — стандартный протокол подключения
|
||||||
|
> AI-ассистентов к инструментам. Этот сервер — небольшая отдельная программа:
|
||||||
|
> она читает запросы от ассистента, выполняет действия (по SSH на боевые
|
||||||
|
> машины) и возвращает результат. Ассистент вызывает его инструменты по
|
||||||
|
> имени — например «покажи статус продукта ups».
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Что умеет (текущая версия)
|
||||||
|
|
||||||
|
| Инструмент | Что делает |
|
||||||
|
|---|---|
|
||||||
|
| `products_list` | Сводка по всем машинам: продукт → машина, состояние, порт, лицензия, версия |
|
||||||
|
| `service_status` | Краткий статус конкретного продукта (unit, порт, состояние, лицензия) |
|
||||||
|
| `repo_catalog` | Список доступных версий продукта в репозитории (ветки release/beta) |
|
||||||
|
| `base_status` | Полнота базовой среды `/opt/pve` на машине |
|
||||||
|
| `service_config_get` | Top-level ключи `config.json` продукта (+ наличие примера схемы) |
|
||||||
|
| `license_get` | Содержимое `license.json` (service, срок, ключ) |
|
||||||
|
| `license_verify` | Проверка лицензии: поля + живой ответ `/license/check` продукта |
|
||||||
|
| `service_logs` | Хвост журнала продукта (опц. фильтр) |
|
||||||
|
| `service_health` | Живой статус + `/openapi.json` (жив/версия) + `/license/check` |
|
||||||
|
| `service_diagnose` | Чек-лист: юнит, порт, лицензия, база `/opt/pve` |
|
||||||
|
| `errors_search` | Поиск ошибок в журнале продукта за окно времени |
|
||||||
|
|
||||||
|
### Изменения (только при `read_only=false` + `allow_mutate` + одобрение)
|
||||||
|
|
||||||
|
| Инструмент | Что делает |
|
||||||
|
|---|---|
|
||||||
|
| `service_install` | Установка продукта (асинхронно, возвращает id); по умолчанию последняя версия |
|
||||||
|
| `service_update` | Обновление до последней версии в ветке (backup инсталлятором) |
|
||||||
|
| `service_uninstall` | Удаление продукта |
|
||||||
|
| `base_ensure` | Создание минимальной базы `/opt/pve` (идемпотентно) |
|
||||||
|
| `service_config_set` | Изменение ключей `config.json` (точечный путь) + рестарт |
|
||||||
|
| `license_apply` | Запись `license.json` + проверка через `/license/check` |
|
||||||
|
| `service_restart` | Перезапуск службы продукта |
|
||||||
|
| `service_install_status` | Прогресс установки по id |
|
||||||
|
|
||||||
|
Установка идёт асинхронно (возвращает `id`, прогресс — `service_install_status`),
|
||||||
|
чтобы не блокировать агента. Для GPU-продуктов (crs, spr) автоматически
|
||||||
|
подхватывается ускоритель (cu13) через `nvidia-smi`.
|
||||||
|
|
||||||
|
## Поддерживаемые продукты
|
||||||
|
|
||||||
|
`acs, ams, cls, crs, llm, qas, res, sbs, see, ses, ses-sip, smc, spr, tts, ups, vds`
|
||||||
|
|
||||||
|
Не поддерживаются: `vllm` (open-source), `spr5` (легаси),
|
||||||
|
`ses-sip-gateway-api` (вспомогательные).
|
||||||
|
|
||||||
|
## Как сервер узнаёт о продуктах
|
||||||
|
|
||||||
|
Ничего не «зашито» в код. Все данные берутся из живой системы:
|
||||||
|
|
||||||
|
- **порт, движок (uvicorn/gunicorn/python/bwrap) и venv** — из действующего
|
||||||
|
systemd-юнита (`ExecStart`) продукта;
|
||||||
|
- **живость и версия** — по `GET /openapi.json` (продукты на FastAPI его отдают);
|
||||||
|
- **лицензия** — из `license.json` + живой проверки `GET /license/check`;
|
||||||
|
- **версии в репозитории** — по web-API SFTPGo (каталог `/repos/<продукт>/<ветка>`).
|
||||||
|
|
||||||
|
Поэтому добавлять/обновлять продукты можно без правки кода — только в конфиге
|
||||||
|
указать, какие продукты на какой машине.
|
||||||
|
|
||||||
|
## Сборка
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go build -o forge-tools-lexicomai .
|
||||||
|
./forge-tools-lexicomai --health # → ok (проверка, что сервер живой)
|
||||||
|
go test -race ./...
|
||||||
|
```
|
||||||
|
|
||||||
|
Зависимости минимальны: общая библиотека `forge-toolkit` + официальный Go-SDK
|
||||||
|
MCP. Собранный бинарь `forge-tools-lexicomai` в репозиторий не коммитится
|
||||||
|
(в `.gitignore`).
|
||||||
|
|
||||||
|
## Подключение сервера
|
||||||
|
|
||||||
|
Сервер запускается как **stdio-процесс** (читает/пишет MCP-протокол через
|
||||||
|
стандартные ввод/вывод). В конфиге MCP-хоста указывается команда запуска и флаг
|
||||||
|
`-config`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "command": "/opt/lexicomai/forge-tools-lexicomai",
|
||||||
|
"args": ["-config", "/etc/lexicomai/lexicomai.json"] }
|
||||||
|
```
|
||||||
|
|
||||||
|
То же самое подходит для любого MCP-клиента.
|
||||||
|
|
||||||
|
## Конфигурация
|
||||||
|
|
||||||
|
Файл `lexicomai.json`. Реальные адреса, ключи и пароли в репозиторий **не
|
||||||
|
попадают** — подставляются переменными окружения `${VAR}` (или берутся из
|
||||||
|
локального файла, который не коммитится).
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"alias": "prod",
|
||||||
|
"ssh": { "host": "${LEXICOMAI_HOST}", "user": "${LEXICOMAI_USER}",
|
||||||
|
"key": "${LEXICOMAI_SSH_KEY}", "port": 22 },
|
||||||
|
"products": ["ups", "spr", "cls"],
|
||||||
|
"allow_mutate": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"default": "prod",
|
||||||
|
"read_only": true,
|
||||||
|
"timeout_seconds": 15,
|
||||||
|
"max_output_bytes": 4194304,
|
||||||
|
"repo": { "base": "https://cloud.connect2ai.net",
|
||||||
|
"user": "${SFTPGO_USER}", "pass": "${SFTPGO_PASS}" }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Поле | Смысл |
|
||||||
|
|---|---|
|
||||||
|
| `targets[]` | Управляемые машины: `alias` (имя), `ssh` (host/user/key/port), `products` (что на ней живёт), `allow_mutate` (разрешены ли изменения) |
|
||||||
|
| `default` | Машина по умолчанию |
|
||||||
|
| `read_only` | Полный запрет мутаций (по умолчанию `true`) |
|
||||||
|
| `timeout_seconds` | Таймаут одного SSH-вызова/пробы |
|
||||||
|
| `max_output_bytes` | Ограничение размера результата |
|
||||||
|
| `repo` | Доступ к репозиторию дистрибутивов (web-клиент SFTPGo) |
|
||||||
|
|
||||||
|
Требования: у машины должен быть SSH-доступ по ключу (пользователь с `sudo -n`);
|
||||||
|
`products[]` объявляет продукты на машине.
|
||||||
|
|
||||||
|
## Безопасность
|
||||||
|
|
||||||
|
- По умолчанию — **только чтение**; мутации включаются явно (`read_only=false`
|
||||||
|
+ `allow_mutate` на конкретной машине) и дополнительно требуют одобрения.
|
||||||
|
- Никаких адресов/подсетей/ключей в коде — всё из конфига/окружения.
|
||||||
|
- Команды на машине выполняются через `sudo -n`; пользовательский ввод
|
||||||
|
экранируется; ключ лицензии в выводе маскируется.
|
||||||
|
- Инструменты ограничены по времени (таймаут) и размеру результата.
|
||||||
|
|
||||||
|
## Проверено
|
||||||
|
|
||||||
|
- `go build`, `go vet`, `go test -race` — зелёные; `gofmt` — чисто.
|
||||||
|
- Ручная проверка MCP (T1): `initialize` → сервер отвечает именем `lexicomai`,
|
||||||
|
`tools/list` возвращает инструменты с корректными обязательными параметрами.
|
||||||
|
|
||||||
|
## Статус
|
||||||
|
|
||||||
|
- **M1 (готово):** каркас + read-only инструменты.
|
||||||
|
- **M3–M5 (готово):** установка/обновление/удаление, изменение конфигурации,
|
||||||
|
применение лицензий, база `/opt/pve`, управление службой — для всех
|
||||||
|
поддерживаемых продуктов. Написано по архитектуре остальных MCP-серверов
|
||||||
|
компании. Live-проверка на боевых машинах — только на стейджинге.
|
||||||
+15
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"alias": "prod",
|
||||||
|
"ssh": { "host": "${LEXICOMAI_HOST}", "user": "${LEXICOMAI_USER}", "key": "${LEXICOMAI_SSH_KEY}", "port": 22 },
|
||||||
|
"products": ["ups", "spr", "cls"],
|
||||||
|
"allow_mutate": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"default": "prod",
|
||||||
|
"read_only": true,
|
||||||
|
"timeout_seconds": 15,
|
||||||
|
"max_output_bytes": 4194304,
|
||||||
|
"repo": { "base": "${SFTPGO_BASE}", "user": "${SFTPGO_USER}", "pass": "${SFTPGO_PASS}" }
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
module forge-tools-lexicomai
|
||||||
|
|
||||||
|
go 1.27.0
|
||||||
|
|
||||||
|
require (
|
||||||
|
git.totmin.ru/en2zmax/forge-toolkit v0.1.0
|
||||||
|
github.com/modelcontextprotocol/go-sdk v1.7.0
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/google/jsonschema-go v0.4.3 // indirect
|
||||||
|
github.com/segmentio/asm v1.1.3 // indirect
|
||||||
|
github.com/segmentio/encoding v0.5.4 // indirect
|
||||||
|
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
|
||||||
|
golang.org/x/oauth2 v0.35.0 // indirect
|
||||||
|
golang.org/x/sync v0.20.0 // indirect
|
||||||
|
golang.org/x/sys v0.41.0 // indirect
|
||||||
|
golang.org/x/time v0.15.0 // indirect
|
||||||
|
)
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
git.totmin.ru/en2zmax/forge-toolkit v0.1.0 h1:d4p1mDzPwG/CuehTXVlyeT0vAWkQmzHG83NLmqUxkAo=
|
||||||
|
git.totmin.ru/en2zmax/forge-toolkit v0.1.0/go.mod h1:4LVtO/yq0SsTP6opDYS32JiDNC/tBBIAwGLGGw/D88Q=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||||
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
|
github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0=
|
||||||
|
github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
|
||||||
|
github.com/modelcontextprotocol/go-sdk v1.7.0 h1:yqjY2dsbKAC0LSuWZVBMrHgiG8ukXv6NRo0JiALay44=
|
||||||
|
github.com/modelcontextprotocol/go-sdk v1.7.0/go.mod h1:dL7u98E/zjJTGzEq+j30jQ8K2k1mb6LeAH4inEcSGts=
|
||||||
|
github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc=
|
||||||
|
github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg=
|
||||||
|
github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0=
|
||||||
|
github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0=
|
||||||
|
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
|
||||||
|
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
|
||||||
|
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
|
||||||
|
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||||
|
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||||
|
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||||
|
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||||
|
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
|
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||||
|
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||||
|
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
||||||
|
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
// Package lexicomai — доменный слой MCP-модуля forge-tools/lexicomai.
|
||||||
|
//
|
||||||
|
// Модуль управляет жизненным циклом продуктов Lexicom AI, развёрнутых на
|
||||||
|
// выделенных машинах: инвентарь, статус, конфигурация, лицензии и диагностика.
|
||||||
|
// Он намеренно НЕ дублирует generic-возможности других forge-tools (ssh —
|
||||||
|
// удалённый exec, filesystem — локальные файлы, proxmox — гипервизор,
|
||||||
|
// web — HTTP): здесь только доменная семантика продукта (установка/конфиг/
|
||||||
|
// лицензия/health/ошибки), а SSH-транспорт — внутренняя «сантехника».
|
||||||
|
//
|
||||||
|
// Пакет ничего не знает про MCP и SDK: это чистая доменная логика, которую
|
||||||
|
// можно тестировать без сети и без SDK.
|
||||||
|
package lexicomai
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.totmin.ru/en2zmax/forge-toolkit"
|
||||||
|
"git.totmin.ru/en2zmax/forge-toolkit/configreload"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Значения по умолчанию. Принцип fail-closed: отсутствие обязательных полей —
|
||||||
|
// ошибка, а не «подозрительный» дефолт.
|
||||||
|
const (
|
||||||
|
tenantConfigFileName = "lexicomai.json"
|
||||||
|
tenantConfigDirEnv = "FORGE_TENANT_CONFIG"
|
||||||
|
|
||||||
|
// DefaultTimeoutSeconds — таймаут одной SSH-команды/HTTP-пробы. Инструменты
|
||||||
|
// обязаны завершаться за конечное время, чтобы не блокировать агента.
|
||||||
|
DefaultTimeoutSeconds = 15
|
||||||
|
// DefaultMaxOutputBytes — кап тела ответа/вывода, чтобы не отдавать модели
|
||||||
|
// гигантские результаты; большие значения обрезаются с флагом *_truncated.
|
||||||
|
DefaultMaxOutputBytes = 4 << 20 // 4 MiB
|
||||||
|
)
|
||||||
|
|
||||||
|
// SSHTarget — параметры подключения к управляемой машине. Хост, пользователь
|
||||||
|
// и ключ задаёт оператор (в per-agent конфиге), модель их не вводит.
|
||||||
|
//
|
||||||
|
// Никакие адреса/подсети/VLAN в код не зашиты: всё приходит из конфига.
|
||||||
|
type SSHTarget struct {
|
||||||
|
Host string `json:"host"`
|
||||||
|
User string `json:"user"`
|
||||||
|
Key string `json:"key"`
|
||||||
|
Port int `json:"port"` // SSH-порт; 0 = 22.
|
||||||
|
}
|
||||||
|
|
||||||
|
// Target — одна управляемая машина (alias) и продукты, которые на ней живут.
|
||||||
|
//
|
||||||
|
// Alias — стабильный идентификатор, на который ссылается модель
|
||||||
|
// («платформа объявляет, модель ссылается»). Products — перечень продуктов
|
||||||
|
// на машине (контролируется оператором). Port — опциональный порт продукта;
|
||||||
|
// если пуст — порт определяется на лету из systemd-юнита/юнитов (не хардкод).
|
||||||
|
type Target struct {
|
||||||
|
Alias string `json:"alias"`
|
||||||
|
SSH SSHTarget `json:"ssh"`
|
||||||
|
Products []string `json:"products"`
|
||||||
|
// Port — порт основного продукта (удобно, когда их несколько и нужен явный
|
||||||
|
// выбор); по пустому значению порт берётся из службы продукта.
|
||||||
|
Port int `json:"port"`
|
||||||
|
// AllowMutate — разрешены ли мутации на этой машине. Второй слой защиты
|
||||||
|
// поверх approval: fail-closed (false = только чтение).
|
||||||
|
AllowMutate bool `json:"allow_mutate"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RepoConfig — доступ к репозиторию дистрибутивов (SFTPGo web-client).
|
||||||
|
//
|
||||||
|
// REST /api/v2 и SFTP снаружи не экспонированы, поэтому используется только
|
||||||
|
// web-client API; креды приходят из ${VAR}/*.local.json, не из кода.
|
||||||
|
type RepoConfig struct {
|
||||||
|
Base string `json:"base"`
|
||||||
|
User string `json:"user"`
|
||||||
|
Pass string `json:"pass"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Config — per-agent конфигурация модуля. Файл-шаблон лежит в репозитории,
|
||||||
|
// реальные значения приходят из <FORGE_TENANT_CONFIG>/lexicomai.json
|
||||||
|
// (live-reload) или из статического -config как фолбэк.
|
||||||
|
type Config struct {
|
||||||
|
// Targets — управляемые машины. Пустой список = fail-closed (работать не с чем).
|
||||||
|
Targets []Target `json:"targets"`
|
||||||
|
// Default — alias машины по умолчанию.
|
||||||
|
Default string `json:"default"`
|
||||||
|
// ReadOnly — полный запрет мутаций (в доп. к allow_mutate/approval).
|
||||||
|
ReadOnly *bool `json:"read_only"`
|
||||||
|
// TimeoutSeconds — таймаут SSH-команд и HTTP-проб.
|
||||||
|
TimeoutSeconds int `json:"timeout_seconds"`
|
||||||
|
// MaxOutputBytes — кап результата.
|
||||||
|
MaxOutputBytes int `json:"max_output_bytes"`
|
||||||
|
// Repo — доступ к репозиторию.
|
||||||
|
Repo RepoConfig `json:"repo"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseConfig разбирает сырые байты конфига: раскрывает ${VAR}, парсит JSON,
|
||||||
|
// применяет дефолты и валидирует. Неразрешённая переменная раскрывается в
|
||||||
|
// пустую строку и затем отвергается валидацией (fail-closed).
|
||||||
|
func ParseConfig(data []byte) (*Config, error) {
|
||||||
|
expanded := toolkit.Expand(data)
|
||||||
|
|
||||||
|
var cfg Config
|
||||||
|
if err := json.Unmarshal(expanded, &cfg); err != nil {
|
||||||
|
return nil, fmt.Errorf("parse config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(cfg.Targets) == 0 {
|
||||||
|
return nil, fmt.Errorf("config: targets is required (empty list — fail-closed)")
|
||||||
|
}
|
||||||
|
for i := range cfg.Targets {
|
||||||
|
t := &cfg.Targets[i]
|
||||||
|
if strings.TrimSpace(t.Alias) == "" {
|
||||||
|
return nil, fmt.Errorf("config: targets[%d].alias is required", i)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(t.SSH.Host) == "" {
|
||||||
|
return nil, fmt.Errorf("config: targets[%d].ssh.host is required", i)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(t.SSH.User) == "" {
|
||||||
|
return nil, fmt.Errorf("config: targets[%d].ssh.user is required", i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if cfg.Default == "" {
|
||||||
|
cfg.Default = cfg.Targets[0].Alias
|
||||||
|
}
|
||||||
|
if cfg.TimeoutSeconds <= 0 {
|
||||||
|
cfg.TimeoutSeconds = DefaultTimeoutSeconds
|
||||||
|
}
|
||||||
|
if cfg.MaxOutputBytes <= 0 {
|
||||||
|
cfg.MaxOutputBytes = DefaultMaxOutputBytes
|
||||||
|
}
|
||||||
|
return &cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Timeout возвращает таймаут одной операции как time.Duration.
|
||||||
|
func (c *Config) Timeout() time.Duration {
|
||||||
|
return time.Duration(c.TimeoutSeconds) * time.Second
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsReadOnly сообщает, находится ли модуль в режиме только чтения.
|
||||||
|
func (c *Config) IsReadOnly() bool {
|
||||||
|
return c.ReadOnly != nil && *c.ReadOnly
|
||||||
|
}
|
||||||
|
|
||||||
|
// Target возвращает машину по alias; пустой alias — «по умолчанию».
|
||||||
|
func (c *Config) Target(alias string) (*Target, error) {
|
||||||
|
if alias == "" {
|
||||||
|
alias = c.Default
|
||||||
|
}
|
||||||
|
for i := range c.Targets {
|
||||||
|
if c.Targets[i].Alias == alias {
|
||||||
|
return &c.Targets[i], nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("target %q not found", alias)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasProduct сообщает, объявлен ли продукт на машине.
|
||||||
|
func (t *Target) HasProduct(product string) bool {
|
||||||
|
for _, p := range t.Products {
|
||||||
|
if p == product {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// SSHAddr возвращает «user@host[:port]» для подключения (порт по умолчанию 22).
|
||||||
|
func (t *Target) SSHAddr() string {
|
||||||
|
host := t.SSH.Host
|
||||||
|
if t.SSH.Port > 0 && t.SSH.Port != 22 {
|
||||||
|
host = fmt.Sprintf("%s:%d", host, t.SSH.Port)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s@%s", t.SSH.User, host)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Loader — источник актуального конфига с live-reload (configreload по контент-хэшу).
|
||||||
|
//
|
||||||
|
// Приоритет: per-agent <FORGE_TENANT_CONFIG>/lexicomai.json → статический -config.
|
||||||
|
type Loader struct {
|
||||||
|
reload *configreload.Loader[*Config]
|
||||||
|
fallbackPath string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewLoader создаёт загрузчик. tenantConfigDir — FORGE_TENANT_CONFIG (может быть
|
||||||
|
// пустым); configPath — статический -config (может быть пустым).
|
||||||
|
func NewLoader(tenantConfigDir, configPath string) *Loader {
|
||||||
|
l := &Loader{fallbackPath: configPath}
|
||||||
|
if tenantConfigDir != "" {
|
||||||
|
l.reload = configreload.New(
|
||||||
|
filepath.Join(tenantConfigDir, tenantConfigFileName),
|
||||||
|
ParseConfig,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return l
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get возвращает актуальный конфиг.
|
||||||
|
func (l *Loader) Get() (*Config, error) {
|
||||||
|
if l.reload != nil {
|
||||||
|
cfg, err := l.reload.Get()
|
||||||
|
if err == nil {
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
// ErrNotFound — штатно (per-agent файла нет) → пробуем фолбэк.
|
||||||
|
// Иная ошибка (битый JSON) — тоже к фолбэку, но без фолбэка не молчим.
|
||||||
|
if !isNotFound(err) && l.fallbackPath == "" {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if l.fallbackPath != "" {
|
||||||
|
data, err := os.ReadFile(l.fallbackPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read config %s: %w", l.fallbackPath, err)
|
||||||
|
}
|
||||||
|
return ParseConfig(data)
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("no config: %s/%s not found and -config is empty",
|
||||||
|
tenantConfigDir(), tenantConfigFileName)
|
||||||
|
}
|
||||||
|
|
||||||
|
func isNotFound(err error) bool {
|
||||||
|
return err != nil && strings.Contains(err.Error(), "not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
func tenantConfigDir() string { return os.Getenv(tenantConfigDirEnv) }
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package lexicomai
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestParseConfig(t *testing.T) {
|
||||||
|
valid := []byte(`{
|
||||||
|
"targets":[{"alias":"a","ssh":{"host":"10.0.0.1","user":"debian","key":"k"},"products":["ups"],"port":6182}],
|
||||||
|
"read_only":true
|
||||||
|
}`)
|
||||||
|
cfg, err := ParseConfig(valid)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseConfig(valid) error: %v", err)
|
||||||
|
}
|
||||||
|
if cfg.Default != "a" {
|
||||||
|
t.Errorf("default = %q, want a", cfg.Default)
|
||||||
|
}
|
||||||
|
if cfg.TimeoutSeconds != DefaultTimeoutSeconds {
|
||||||
|
t.Errorf("timeout default = %d, want %d", cfg.TimeoutSeconds, DefaultTimeoutSeconds)
|
||||||
|
}
|
||||||
|
if cfg.IsReadOnly() != true {
|
||||||
|
t.Error("IsReadOnly = false, want true")
|
||||||
|
}
|
||||||
|
if _, err := cfg.Target("missing"); err == nil {
|
||||||
|
t.Error("Target(missing) should error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseConfigRejectsEmptyTargets(t *testing.T) {
|
||||||
|
if _, err := ParseConfig([]byte(`{"targets":[]}`)); err == nil {
|
||||||
|
t.Fatal("empty targets should be rejected (fail-closed)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseConfigRejectsMissingHost(t *testing.T) {
|
||||||
|
bad := []byte(`{"targets":[{"alias":"a","ssh":{"user":"debian"}}]}`)
|
||||||
|
if _, err := ParseConfig(bad); err == nil {
|
||||||
|
t.Fatal("missing ssh.host should be rejected")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTargetHasProductAndAddr(t *testing.T) {
|
||||||
|
tg := &Target{Alias: "a", SSH: SSHTarget{Host: "10.0.0.1", User: "debian", Port: 22}, Products: []string{"ups", "spr"}}
|
||||||
|
if !tg.HasProduct("ups") {
|
||||||
|
t.Error("HasProduct(ups) = false")
|
||||||
|
}
|
||||||
|
if tg.HasProduct("ses") {
|
||||||
|
t.Error("HasProduct(ses) = true, want false")
|
||||||
|
}
|
||||||
|
if got := tg.SSHAddr(); got != "debian@10.0.0.1" {
|
||||||
|
t.Errorf("SSHAddr = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
package lexicomai
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ServiceInfo — описание службы продукта, извлечённое из systemd-юнита и
|
||||||
|
// HTTP-проб. Всё выводится из живой системы (не захардкожено): движок, порт,
|
||||||
|
// venv и лицензия определяются на лету.
|
||||||
|
type ServiceInfo struct {
|
||||||
|
Unit string
|
||||||
|
Active bool
|
||||||
|
SubState string
|
||||||
|
Engine string // uvicorn / gunicorn / python / bwrap / other
|
||||||
|
Binary string // путь к исполняемому (например /opt/pve/bin/uvicorn)
|
||||||
|
Venv string // корень venv (например /opt/pve/ups)
|
||||||
|
Port int
|
||||||
|
RawExec string
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
reActive = regexp.MustCompile(`ActiveState=([A-Za-z]+)`)
|
||||||
|
reSub = regexp.MustCompile(`SubState=([A-Za-z]+)`)
|
||||||
|
reBind = regexp.MustCompile(`(?:--bind\s+\S*:(\d+)|0\.0\.0\.0:(\d+)|--port\s+(\d+)|127\.0\.0\.1:(\d+))`)
|
||||||
|
reBin = regexp.MustCompile(`(?:/opt/pve[^ ;]*/bin/([a-zA-Z0-9_.-]+)|/usr/bin/bwrap|/opt/[a-zA-Z0-9_.-]+/bin/python[a-z0-9]*)`)
|
||||||
|
)
|
||||||
|
|
||||||
|
// parsePortAndEngine извлекает порт, движок и бинарник из строки ExecStart.
|
||||||
|
func parsePortAndEngine(execRaw string) (port int, engine, binary string) {
|
||||||
|
if m := reBind.FindStringSubmatch(execRaw); m != nil {
|
||||||
|
for _, g := range m[1:] {
|
||||||
|
if g != "" {
|
||||||
|
if p, err := strconv.Atoi(g); err == nil {
|
||||||
|
port = p
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
bin := ""
|
||||||
|
if m := reBin.FindString(execRaw); m != "" {
|
||||||
|
bin = m
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case strings.Contains(bin, "/bin/uvicorn"):
|
||||||
|
engine = "uvicorn"
|
||||||
|
case strings.Contains(bin, "/bin/gunicorn"):
|
||||||
|
engine = "gunicorn"
|
||||||
|
case strings.Contains(bin, "/bin/python"):
|
||||||
|
engine = "python"
|
||||||
|
case strings.Contains(strings.ToLower(execRaw), "bwrap"):
|
||||||
|
engine = "bwrap"
|
||||||
|
default:
|
||||||
|
engine = "other"
|
||||||
|
}
|
||||||
|
return port, engine, bin
|
||||||
|
}
|
||||||
|
|
||||||
|
// venvFromBinary берёт корень venv из пути бинарника:
|
||||||
|
//
|
||||||
|
// /opt/pve/ups/bin/uvicorn -> /opt/pve/ups
|
||||||
|
// /opt/pve/bin/uvicorn -> /opt/pve
|
||||||
|
func venvFromBinary(binary string) string {
|
||||||
|
if binary == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
idx := strings.Index(binary, "/bin/")
|
||||||
|
if idx <= 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return binary[:idx]
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServiceQuery возвращает состояние службы продукта на машине.
|
||||||
|
//
|
||||||
|
// Источник истины — действующий systemd-юнит (ExecStart/ActiveState), поэтому
|
||||||
|
// двигатель/порт/venv не дублируются в коде и не зависят от версии продукта.
|
||||||
|
func (m *Manager) ServiceQuery(ctx context.Context, target *Target, product string) (*ServiceInfo, error) {
|
||||||
|
unit := ServiceUnit(product)
|
||||||
|
out, err := m.runSudo(ctx, target, "systemctl show -p ExecStart,ActiveState,SubState "+shellQuote(unit))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("query unit %s: %w", unit, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
info := &ServiceInfo{Unit: unit, Engine: "other"}
|
||||||
|
info.Active = reActive.MatchString(out) && reActive.FindStringSubmatch(out)[1] == "active"
|
||||||
|
if m := reSub.FindStringSubmatch(out); m != nil {
|
||||||
|
info.SubState = m[1]
|
||||||
|
}
|
||||||
|
// ExecStart-строка — внутри `ExecStart={ ... }`.
|
||||||
|
execLine := firstLineContaining(out, "ExecStart=")
|
||||||
|
raw := strings.TrimSpace(execLine)
|
||||||
|
info.RawExec = raw
|
||||||
|
port, engine, binary := parsePortAndEngine(raw)
|
||||||
|
info.Port = port
|
||||||
|
info.Engine = engine
|
||||||
|
info.Binary = binary
|
||||||
|
info.Venv = venvFromBinary(binary)
|
||||||
|
return info, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// firstLineContaining возвращает первую строку, содержащую подстроку needle.
|
||||||
|
func firstLineContaining(s, needle string) string {
|
||||||
|
for _, line := range strings.Split(s, "\n") {
|
||||||
|
if strings.Contains(line, needle) {
|
||||||
|
return line
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenAPI — метаданные живого продукта, когда они доступны (FastAPI/uvicorn
|
||||||
|
// отдают /openapi.json; 200 = сервис жив).
|
||||||
|
type OpenAPI struct {
|
||||||
|
Title string
|
||||||
|
Version string
|
||||||
|
Alive bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProbeOpenAPI читает /openapi.json продукта (если движок его отдаёт) для
|
||||||
|
// подтверждения живости и извлечения версии. Ошибка (недоступен порт/нет
|
||||||
|
// openapi) — не блокирует: приводятся в health как «не проверено».
|
||||||
|
func (m *Manager) ProbeOpenAPI(ctx context.Context, target *Target, port int) (OpenAPI, error) {
|
||||||
|
status, body, err := m.probeHTTP(ctx, target, port, "/openapi.json")
|
||||||
|
if err != nil {
|
||||||
|
return OpenAPI{}, err
|
||||||
|
}
|
||||||
|
info := OpenAPI{Alive: status == 200}
|
||||||
|
if status != 200 {
|
||||||
|
return info, fmt.Errorf("openapi.json http %d", status)
|
||||||
|
}
|
||||||
|
info.Title = jsonString(body, "title")
|
||||||
|
info.Version = jsonString(body, "version")
|
||||||
|
return info, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProbeLicenseCheck вызывает /license/check продукта. HTTP 200 обычно означает,
|
||||||
|
// что лицензия валидна; 4xx/5xx — отклонена/недоступна. Итог нормализуется в
|
||||||
|
// булев признак (body не логируется целиком).
|
||||||
|
func (m *Manager) ProbeLicenseCheck(ctx context.Context, target *Target, port int) (ok bool, statusCode int, err error) {
|
||||||
|
status, _, err := m.probeHTTP(ctx, target, port, "/license/check")
|
||||||
|
if err != nil {
|
||||||
|
return false, 0, err
|
||||||
|
}
|
||||||
|
return status >= 200 && status < 300, status, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func jsonString(body, key string) string {
|
||||||
|
// Лёгкий поиск "key":"value" без привязки к структуре — идеально для
|
||||||
|
// info.title/info.version в openapi.json. Полный JSON-парсинг не нужен.
|
||||||
|
re := regexp.MustCompile(`"` + regexp.QuoteMeta(key) + `"\s*:\s*"([^"]*)"`)
|
||||||
|
if m := re.FindStringSubmatch(body); m != nil {
|
||||||
|
return m[1]
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package lexicomai
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestParsePortAndEngine(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
exec string
|
||||||
|
port int
|
||||||
|
engine string
|
||||||
|
}{
|
||||||
|
{"uvicorn per-svc", `ExecStart={ path=/opt/pve/ups/bin/uvicorn ; argv[]=/opt/pve/ups/bin/uvicorn main:app --host 0.0.0.0 --port 6182 ; }`, 6182, "uvicorn"},
|
||||||
|
{"ubicorn shared", `ExecStart={ path=/opt/pve/bin/uvicorn ; argv[]=/opt/pve/bin/uvicorn main:app --host 0.0.0.0 --port 6110 ; }`, 6110, "uvicorn"},
|
||||||
|
{"gunicorn", `ExecStart={ path=/opt/pve/bin/gunicorn ; argv[]=/opt/pve/bin/gunicorn --bind 0.0.0.0:6170 ; }`, 6170, "gunicorn"},
|
||||||
|
{"python api", `ExecStart={ path=/opt/pve/bin/python ; argv[]=/opt/pve/bin/python /opt/ses-sip-gateway-api/api.py ; }`, 0, "python"},
|
||||||
|
{"bwrap", `ExecStart={ path=/usr/bin/bwrap ; argv[]=/usr/bin/bwrap --bind /opt/ses-sip/image / --tmpfs /tmp ; }`, 0, "bwrap"},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
port, engine, _ := parsePortAndEngine(c.exec)
|
||||||
|
if port != c.port {
|
||||||
|
t.Errorf("port = %d, want %d", port, c.port)
|
||||||
|
}
|
||||||
|
if engine != c.engine {
|
||||||
|
t.Errorf("engine = %q, want %q", engine, c.engine)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVenvFromBinary(t *testing.T) {
|
||||||
|
cases := map[string]string{
|
||||||
|
"/opt/pve/ups/bin/uvicorn": "/opt/pve/ups",
|
||||||
|
"/opt/pve/bin/uvicorn": "/opt/pve",
|
||||||
|
"": "",
|
||||||
|
}
|
||||||
|
for in, want := range cases {
|
||||||
|
if got := venvFromBinary(in); got != want {
|
||||||
|
t.Errorf("venvFromBinary(%q) = %q, want %q", in, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestShellQuote(t *testing.T) {
|
||||||
|
if got := shellQuote("a'b"); got != "'a'\\''b'" {
|
||||||
|
t.Errorf("shellQuote(%q) = %q", "a'b", got)
|
||||||
|
}
|
||||||
|
if got := shellQuote("plain"); got != "'plain'" {
|
||||||
|
t.Errorf("shellQuote(%q) = %q", "plain", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestJSONString(t *testing.T) {
|
||||||
|
body := `{"info":{"version":"7.005 release","title":"Call List Service"}}`
|
||||||
|
if got := jsonString(body, "version"); got != "7.005 release" {
|
||||||
|
t.Errorf("jsonString(version) = %q", got)
|
||||||
|
}
|
||||||
|
if got := jsonString(body, "title"); got != "Call List Service" {
|
||||||
|
t.Errorf("jsonString(title) = %q", got)
|
||||||
|
}
|
||||||
|
if got := jsonString(body, "missing"); got != "" {
|
||||||
|
t.Errorf("jsonString(missing) = %q, want empty", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package lexicomai
|
||||||
|
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
// Доменные ошибки. Инструменты превращают их в errorResult (recoverable —
|
||||||
|
// модель видит и может исправить), а не в Go-ошибку.
|
||||||
|
|
||||||
|
var (
|
||||||
|
errProductRequired = fmt.Errorf("product is required")
|
||||||
|
)
|
||||||
|
|
||||||
|
// UnknownProductError — модель запросила продукт вне поддерживаемого перечня.
|
||||||
|
type UnknownProductError struct{ Name string }
|
||||||
|
|
||||||
|
func (e *UnknownProductError) Error() string {
|
||||||
|
return fmt.Sprintf("unknown product %q (not in the supported company products)", e.Name)
|
||||||
|
}
|
||||||
@@ -0,0 +1,246 @@
|
|||||||
|
package lexicomai
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// baseSetupTimeout — таймаут на "тяжёлое" создание базы /opt/pve (apt + uv +
|
||||||
|
// загрузка python + venv). Первичная установка базы может занять минуты, поэтому
|
||||||
|
// такой шаг не режется обычным (15с) таймаутом инструмента.
|
||||||
|
const baseSetupTimeout = 20 * time.Minute
|
||||||
|
|
||||||
|
// baseSetupScript — команды подготовки минимальной базы /opt/pve.
|
||||||
|
// Идемпотентно: создаёт venv на python3.12, ставит cythonize/gunicorn/uvicorn
|
||||||
|
// и пишет .version. uv и build-essential доставляет сама, чтобы инсталлятор
|
||||||
|
// продуктов («install_pve») не зависел от предустановленного uv.
|
||||||
|
//
|
||||||
|
// Шаги, обязательные для корректной базы, не глушатся: set -e останавливает
|
||||||
|
// скрипт при первой же ошибке, и она доходит до инструмента (иначе «битая»
|
||||||
|
// база молча выдавалась бы за успешную). apt-get update допускает сбой
|
||||||
|
// (сеть может быть недоступна на момент шага) — это не фатально.
|
||||||
|
var baseSetupScript = `
|
||||||
|
set -e
|
||||||
|
export DEBIAN_FRONTEND=noninteractive
|
||||||
|
# Заголовки ядра и python-dev ставит сам инсталлятор продукта (install_pve),
|
||||||
|
# поэтому в базе они не обязательны и не запрашиваем (имя пакета
|
||||||
|
# linux-headers-$(uname -r) не всегда существует — из-за него база падала).
|
||||||
|
apt-get update -y || true
|
||||||
|
apt-get install -y curl
|
||||||
|
if ! command -v uv >/dev/null 2>&1; then
|
||||||
|
curl -LsSf https://astral.sh/uv/install.sh | env UV_INSTALL_DIR=/usr/local/bin sh
|
||||||
|
fi
|
||||||
|
uv python install 3.12.12
|
||||||
|
uv venv /opt/pve --python 3.12.12
|
||||||
|
uv pip install --python /opt/pve/bin/python cython gunicorn uvicorn
|
||||||
|
echo "7.006 release" > /opt/pve/.version
|
||||||
|
echo "pve base ready"
|
||||||
|
`
|
||||||
|
|
||||||
|
// ensureBase создаёт минимальную базу /opt/pve, если её нет (идемпотентно).
|
||||||
|
// Возвращает текст статуса для пользователя.
|
||||||
|
func (m *Manager) ensureBase(ctx context.Context, target *Target) (string, error) {
|
||||||
|
exists, err := m.runSudo(ctx, target, "test -x /opt/pve/bin/python && test -f /opt/pve/.version && echo yes || echo no")
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(exists) == "yes" {
|
||||||
|
return "pve base already present", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
longCtx, cancel := context.WithTimeout(ctx, baseSetupTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
res, err := m.ExecWithInput(longCtx, target, "sudo -n bash -s", strings.NewReader(baseSetupScript))
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("base setup: %w", err)
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(res.Output), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// BaseEnsure гарантирует минимальную базу /opt/pve на машине (публичный
|
||||||
|
// инструмент для администратора). Идемпотентна. Проходит гейт мутаций.
|
||||||
|
func (m *Manager) BaseEnsure(ctx context.Context, alias string) (string, error) {
|
||||||
|
_, target, err := m.mutableTarget(alias)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return m.ensureBase(ctx, target)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Install запускает установку продукта на машине (асинхронно) и возвращает
|
||||||
|
// id инстал-сессии для опроса статуса. Может быть использовано и как
|
||||||
|
// «обновление» (с пустой версией берётся последняя в ветке). Мутация.
|
||||||
|
func (m *Manager) Install(ctx context.Context, alias, product, version, branch string) (string, error) {
|
||||||
|
_, target, err := m.mutableTarget(alias)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
// Сначала ищем дистрибутив, уже лежащий в /opt/distr на машине (офлайн/
|
||||||
|
// стейджинг, без повторного скачивания больших файлов). Если его нет —
|
||||||
|
// резолвим и качаем из репозитория.
|
||||||
|
name, err := m.distroName(ctx, target, product, version, branch)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if _, err := m.ensureBase(ctx, target); err != nil {
|
||||||
|
return "", fmt.Errorf("base ensure: %w", err)
|
||||||
|
}
|
||||||
|
if !m.distroStaged(ctx, target, name) {
|
||||||
|
if err := m.pushDistro(ctx, target, name); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Пути сгенерированы модулем (без пробелов/спецсимволов), поэтому их можно
|
||||||
|
// подставлять внутрь одинарных кавычек bash -c без повторного квотирования
|
||||||
|
// (shellQuote дал бы вложенные кавычки и сломал бы команду).
|
||||||
|
distPath := "/opt/distr/" + name
|
||||||
|
|
||||||
|
logFile := "/tmp/inst_" + name + ".log"
|
||||||
|
cmd := fmt.Sprintf("sudo -n bash -c 'nohup bash %s --install > %s 2>&1 & echo $!'",
|
||||||
|
distPath, logFile)
|
||||||
|
if _, err := m.ExecOnTarget(ctx, target, cmd); err != nil {
|
||||||
|
return "", fmt.Errorf("launch install: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sess := m.sessions.add(target.Alias, product, branch, version, name, logFile)
|
||||||
|
return sess.ID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// pushDistro стримит дистрибутив из репозитория на машину в /opt/distr/
|
||||||
|
// (через sudo tee, без буферизации файла целиком).
|
||||||
|
func (m *Manager) pushDistro(ctx context.Context, target *Target, name string) error {
|
||||||
|
// Ветка и продукт нужны для пути в репозитории; имя файла уже известно.
|
||||||
|
// Достаём продукт/ветку из имени, чтобы открыть правильный поток.
|
||||||
|
parts, err := parseDistroName(name)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := m.runSudo(ctx, target, "mkdir -p /opt/distr"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
stream, err := m.RepoStream(ctx, parts.Product, parts.Branch, name)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer stream.Close()
|
||||||
|
|
||||||
|
cmd := "sudo -n tee " + shellQuote("/opt/distr/"+name) + " >/dev/null && sudo -n chmod +x " + shellQuote("/opt/distr/"+name)
|
||||||
|
if _, err := m.ExecWithInput(ctx, target, cmd, stream); err != nil {
|
||||||
|
return fmt.Errorf("push distro: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// distroName возвращает имя дистрибутива. Приоритет — уже загруженные файлы в
|
||||||
|
// /opt/distr (офлайн/стейджинг); если подходящего нет — обращение к репозиторию.
|
||||||
|
func (m *Manager) distroName(ctx context.Context, target *Target, product, version, branch string) (string, error) {
|
||||||
|
branch, err := normalizeBranch(branch)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
out, err := m.runSudo(ctx, target, "ls -1 /opt/distr/"+shellQuote(product)+"-*.sh 2>/dev/null")
|
||||||
|
if err != nil {
|
||||||
|
// Каталога/файлов нет — ls вернул не-ноль; это не фатально, идём в репо.
|
||||||
|
out = ""
|
||||||
|
}
|
||||||
|
var names []string
|
||||||
|
for _, n := range strings.Split(out, "\n") {
|
||||||
|
if n = strings.TrimSpace(n); n != "" {
|
||||||
|
names = append(names, filepath.Base(n))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(names) > 0 {
|
||||||
|
if name, err := ResolveDistro(names, product, version, branch); err == nil {
|
||||||
|
return name, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return m.RepoResolve(ctx, product, version, branch)
|
||||||
|
}
|
||||||
|
|
||||||
|
// distroStaged сообщает, лежит ли уже дистрибутив на машине (не скачиваем повторно).
|
||||||
|
func (m *Manager) distroStaged(ctx context.Context, target *Target, name string) bool {
|
||||||
|
out, err := m.runSudo(ctx, target, "test -f /opt/distr/"+shellQuote(name)+" && echo yes || echo no")
|
||||||
|
return err == nil && strings.TrimSpace(out) == "yes"
|
||||||
|
}
|
||||||
|
|
||||||
|
// InstallStatus возвращает прогресс/результат установки по сессии.
|
||||||
|
func (m *Manager) InstallStatus(ctx context.Context, target *Target, sess *InstallSession) (string, error) {
|
||||||
|
logExists, err := m.runSudo(ctx, target, "test -f "+shellQuote(sess.LogFile)+" && echo yes || echo no")
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(logExists) != "yes" {
|
||||||
|
return fmt.Sprintf("install %s/%s: started (no log yet)", sess.Product, sess.Branch), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
tail, err := m.runSudo(ctx, target, "tail -n 40 "+shellQuote(sess.LogFile))
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
active := "inactive"
|
||||||
|
if out, err := m.runSudo(ctx, target, "systemctl is-active "+shellQuote(ServiceUnit(sess.Product))); err == nil {
|
||||||
|
active = strings.TrimSpace(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
state := "running"
|
||||||
|
switch {
|
||||||
|
case strings.Contains(tail, "Успех"):
|
||||||
|
state = "success"
|
||||||
|
case strings.Contains(tail, "Ошибка"):
|
||||||
|
state = "error"
|
||||||
|
case active == "active" || active == "failed":
|
||||||
|
state = "finished (unit " + active + ")"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Хвост лога без маркеров "move ..." — конец инсталлятора обычно содержит
|
||||||
|
// полезный итог (порт/логин/пароль).
|
||||||
|
return fmt.Sprintf("install %s/%s: %s (unit=%s)\n%s",
|
||||||
|
sess.Product, sess.Branch, state, active, lastMeaningfulLine(tail)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Uninstall удаляет продукт с машины через инсталлятор --uninstall. Мутация.
|
||||||
|
// Инсталлятор спрашивает подтверждение — скормить 'y' интерактивному промпту,
|
||||||
|
// иначе без tty он отменяет удаление.
|
||||||
|
func (m *Manager) Uninstall(ctx context.Context, alias, product string) (string, error) {
|
||||||
|
_, target, err := m.mutableTarget(alias)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
// Инсталлятор может быть не на месте (поставлен ранее) — берём из distr.
|
||||||
|
script := fmt.Sprintf(`
|
||||||
|
f=$(ls /opt/distr/%s-*.sh 2>/dev/null | head -1)
|
||||||
|
if [ -z "$f" ]; then echo "NO_INSTALLER"; exit 0; fi
|
||||||
|
printf 'y\n' | sudo -n bash "$f" --uninstall || echo "UNINSTALL_FAILED"
|
||||||
|
echo "UNINSTALL_DONE"
|
||||||
|
`, product)
|
||||||
|
out, err := m.ExecWithInput(ctx, target, "sudo -n bash -s", strings.NewReader(script))
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
switch c := out.Combined; {
|
||||||
|
case strings.Contains(c, "NO_INSTALLER"):
|
||||||
|
return "", errors.New("no installer for uninstall (distro not present)")
|
||||||
|
case strings.Contains(c, "UNINSTALL_FAILED"):
|
||||||
|
return "", errors.New("uninstall command failed")
|
||||||
|
default:
|
||||||
|
return "uninstall complete", nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// lastMeaningfulLine ищет последнюю непустую строку, не начинающуюся с «move».
|
||||||
|
func lastMeaningfulLine(out string) string {
|
||||||
|
lines := strings.Split(out, "\n")
|
||||||
|
for i := len(lines) - 1; i >= 0; i-- {
|
||||||
|
l := strings.TrimSpace(lines[i])
|
||||||
|
if l != "" && !strings.HasPrefix(l, "move ") && !strings.HasPrefix(l, ">") {
|
||||||
|
return l
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
package lexicomai
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// License — содержимое license.json продукта. Формат (подтверждён на прод-боксе):
|
||||||
|
//
|
||||||
|
// { "service": "cls", "expire": "2045-01-01", "licenses": 0, "key": "50e1...9cf" }
|
||||||
|
//
|
||||||
|
// Key — подписанная последовательность (service+expire+licenses+uuid), её
|
||||||
|
// генерация — на стороне вендора; модуль только валидирует и применяет.
|
||||||
|
type License struct {
|
||||||
|
Service string `json:"service"`
|
||||||
|
Expire string `json:"expire"`
|
||||||
|
Licenses int `json:"licenses"`
|
||||||
|
Key string `json:"key"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseLicense разбирает license.json. Отсутствующий файл — доменная ошибка
|
||||||
|
// (из неё инструмент делает понятный errorResult).
|
||||||
|
func ParseLicense(data []byte) (*License, error) {
|
||||||
|
var l License
|
||||||
|
if err := json.Unmarshal(data, &l); err != nil {
|
||||||
|
return nil, fmt.Errorf("parse license: %w", err)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(l.Service) == "" {
|
||||||
|
return nil, fmt.Errorf("license: service is empty")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(l.Expire) == "" {
|
||||||
|
return nil, fmt.Errorf("license: expire is empty")
|
||||||
|
}
|
||||||
|
return &l, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerifyFields проверяет поля лицензии без обращения к сервису: привязана ли
|
||||||
|
// она к нужному продукту и не истекла ли. Возвращает список замечаний (пустой —
|
||||||
|
// поля корректны).
|
||||||
|
func (l *License) VerifyFields(product string) []string {
|
||||||
|
var notes []string
|
||||||
|
if strings.TrimSpace(l.Service) != product {
|
||||||
|
notes = append(notes, fmt.Sprintf("license bound to %q, wanted %q", l.Service, product))
|
||||||
|
}
|
||||||
|
if exp, err := time.Parse("2006-01-02", l.Expire); err == nil {
|
||||||
|
if time.Now().After(exp) {
|
||||||
|
notes = append(notes, fmt.Sprintf("license expired on %s", l.Expire))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
notes = append(notes, fmt.Sprintf("license expire %q is not YYYY-MM-DD", l.Expire))
|
||||||
|
}
|
||||||
|
if l.Licenses == 0 {
|
||||||
|
notes = append(notes, "license has 0 seats")
|
||||||
|
}
|
||||||
|
return notes
|
||||||
|
}
|
||||||
|
|
||||||
|
// KeyMasked возвращает ключ лицензии с маской — полный key не выводим
|
||||||
|
// (может быть чувствительным). Короткие ключи скрываются целиком.
|
||||||
|
func (l *License) KeyMasked() string {
|
||||||
|
k := strings.TrimSpace(l.Key)
|
||||||
|
if k == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if len(k) <= 6 {
|
||||||
|
return "***"
|
||||||
|
}
|
||||||
|
return k[:3] + "…" + k[len(k)-3:]
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package lexicomai
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestParseLicense(t *testing.T) {
|
||||||
|
l, err := ParseLicense([]byte(`{"service":"cls","expire":"2045-01-01","licenses":0,"key":"abc"}`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseLicense error: %v", err)
|
||||||
|
}
|
||||||
|
if l.Service != "cls" || l.Expire != "2045-01-01" || l.Licenses != 0 || l.Key != "abc" {
|
||||||
|
t.Errorf("unexpected license %+v", l)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := ParseLicense([]byte(`{}`)); err == nil {
|
||||||
|
t.Error("empty license should error (missing service)")
|
||||||
|
}
|
||||||
|
if _, err := ParseLicense([]byte(`not json`)); err == nil {
|
||||||
|
t.Error("malformed license should error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVerifyFields(t *testing.T) {
|
||||||
|
good := &License{Service: "cls", Expire: "2099-01-01", Licenses: 5, Key: "k"}
|
||||||
|
if notes := good.VerifyFields("cls"); len(notes) != 0 {
|
||||||
|
t.Errorf("good license warnings: %v", notes)
|
||||||
|
}
|
||||||
|
|
||||||
|
wrong := &License{Service: "spr", Expire: "2099-01-01", Licenses: 5}
|
||||||
|
if notes := wrong.VerifyFields("cls"); len(notes) == 0 {
|
||||||
|
t.Error("wrong service should warn")
|
||||||
|
}
|
||||||
|
|
||||||
|
expired := &License{Service: "cls", Expire: "2000-01-01", Licenses: 5}
|
||||||
|
if notes := expired.VerifyFields("cls"); len(notes) == 0 {
|
||||||
|
t.Error("expired license should warn")
|
||||||
|
}
|
||||||
|
|
||||||
|
noSeats := &License{Service: "cls", Expire: "2099-01-01", Licenses: 0}
|
||||||
|
if notes := noSeats.VerifyFields("cls"); len(notes) == 0 {
|
||||||
|
t.Error("0 seats should warn")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
package lexicomai
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Manager — одиночный диспетчер состояния процесса: источник конфига
|
||||||
|
// (live-reload) и блокировки по машинам. Потокобезопасен: агенты вызывают
|
||||||
|
// инструменты конкурентно.
|
||||||
|
//
|
||||||
|
// SSH-клиенты не кэшируются (каждый вызов — отдельный процесс, см. ssh.go),
|
||||||
|
// поэтому неизменяемого состояния, кроме конфига и блокировок, нет.
|
||||||
|
type Manager struct {
|
||||||
|
loader *Loader
|
||||||
|
|
||||||
|
// mu защищает locks. RWMutex не нужен, т.к. locks меняется редко и коротко,
|
||||||
|
// а сами блокировки захватываются отдельно.
|
||||||
|
mu sync.Mutex
|
||||||
|
locks map[string]*sync.Mutex
|
||||||
|
|
||||||
|
http *http.Client
|
||||||
|
sessions *installSessions
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewManager создаёт диспетчер поверх загрузчика конфига.
|
||||||
|
func NewManager(loader *Loader) *Manager {
|
||||||
|
return &Manager{
|
||||||
|
loader: loader,
|
||||||
|
locks: make(map[string]*sync.Mutex),
|
||||||
|
sessions: newInstallSessions(),
|
||||||
|
http: &http.Client{
|
||||||
|
// Каждый запрос дополнительно ограничивается context (см. probeHTTP).
|
||||||
|
Timeout: 10 * time.Second,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close освобождает ресурсы. Вызывается из main через defer.
|
||||||
|
func (m *Manager) Close() {
|
||||||
|
m.http.CloseIdleConnections()
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolve возвращает актуальный конфиг (live-reload применяется на лету).
|
||||||
|
func (m *Manager) resolve() (*Config, error) {
|
||||||
|
return m.loader.Get()
|
||||||
|
}
|
||||||
|
|
||||||
|
// timeout возвращает таймаут одной SSH-операции из актуального конфига.
|
||||||
|
func (m *Manager) timeout() time.Duration {
|
||||||
|
cfg, err := m.resolve()
|
||||||
|
if err != nil {
|
||||||
|
return DefaultTimeoutSeconds * time.Second
|
||||||
|
}
|
||||||
|
return cfg.Timeout()
|
||||||
|
}
|
||||||
|
|
||||||
|
// probeHTTP выполняет read-only HTTP GET к продукту на машине (host:port) и
|
||||||
|
// возвращает статус и (обрезанное) тело. Используется для /openapi.json
|
||||||
|
// (живость/версия) и /license/check (валидность лицензии).
|
||||||
|
func (m *Manager) probeHTTP(ctx context.Context, target *Target, port int, path string) (int, string, error) {
|
||||||
|
if port <= 0 {
|
||||||
|
return 0, "", fmt.Errorf("port not known for %s", target.Alias)
|
||||||
|
}
|
||||||
|
url := fmt.Sprintf("http://%s:%d%s", target.SSH.Host, port, path)
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return 0, "", err
|
||||||
|
}
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
resp, err := m.http.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return 0, "", err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||||
|
return resp.StatusCode, string(body), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// lock возвращает эксклюзивную блокировку машины (сериализация мутаций/
|
||||||
|
// установок на один таргет). Конкурентные agents не должны гонять установки
|
||||||
|
// на одну машину параллельно.
|
||||||
|
func (m *Manager) lock(alias string) *sync.Mutex {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
l, ok := m.locks[alias]
|
||||||
|
if !ok {
|
||||||
|
l = &sync.Mutex{}
|
||||||
|
m.locks[alias] = l
|
||||||
|
}
|
||||||
|
return l
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadConfigFor alias возвращает конфиг и таргет; alias="" → значение по умолчанию.
|
||||||
|
func (m *Manager) loadConfigFor(alias string) (*Config, *Target, error) {
|
||||||
|
cfg, err := m.resolve()
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
t, err := cfg.Target(alias)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
return cfg, t, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CurrentConfig возвращает актуальный конфиг (для проверки политики в tools).
|
||||||
|
func (m *Manager) CurrentConfig() (*Config, error) {
|
||||||
|
return m.resolve()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Target возвращает машину по alias (пустой alias — «по умолчанию»).
|
||||||
|
func (m *Manager) Target(alias string) (*Target, error) {
|
||||||
|
_, t, err := m.loadConfigFor(alias)
|
||||||
|
return t, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Session возвращает инстал-сессию по id (для service_install_status).
|
||||||
|
func (m *Manager) Session(id string) (*InstallSession, bool) {
|
||||||
|
return m.sessions.get(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensureMutationAllowed — доменный гейт мутаций: они возможны только при
|
||||||
|
// read_only=false глобально И allow_mutate=true на конкретной машине
|
||||||
|
// (fail-closed: любое из условий нарушено — отказ). Независимый слой поверх
|
||||||
|
// approval человека (approval держит ядро/agent.yaml).
|
||||||
|
func (m *Manager) ensureMutationAllowed(cfg *Config, target *Target) error {
|
||||||
|
if cfg.IsReadOnly() {
|
||||||
|
return fmt.Errorf("module is read-only (read_only=true) — mutations disabled")
|
||||||
|
}
|
||||||
|
if !target.AllowMutate {
|
||||||
|
return fmt.Errorf("target %q has allow_mutate=false — mutations disabled", target.Alias)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// mutableTarget резолвит машину и проверяет разрешение на мутацию
|
||||||
|
// (используется доменными методами записи, чтобы гейт был защищён на месте).
|
||||||
|
func (m *Manager) mutableTarget(alias string) (*Config, *Target, error) {
|
||||||
|
cfg, t, err := m.loadConfigFor(alias)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
if err := m.ensureMutationAllowed(cfg, t); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
return cfg, t, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
package lexicomai
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Мутирующие доменные операции (M4–M5): изменение конфигурации, применение
|
||||||
|
// лицензии и управление службой. Все они идут через mutableTarget — то есть
|
||||||
|
// гейт read_only/allow_mutate проверяется до любого действия (fail-closed),
|
||||||
|
// а одобрение человека держит ядро/agent.yaml.
|
||||||
|
|
||||||
|
// ConfigSet обновляет ключи config.json продукта и перезапускает службу.
|
||||||
|
// updates — map ключей (точечный путь, например "logs.level") → значение.
|
||||||
|
// Возвращает итог (новые/изменённые ключи + результат рестарта).
|
||||||
|
func (m *Manager) ConfigSet(ctx context.Context, alias, product string, updates map[string]any) (string, error) {
|
||||||
|
_, target, err := m.mutableTarget(alias)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
path := "/opt/" + product + "/config.json"
|
||||||
|
|
||||||
|
raw, err := m.runSudo(ctx, target, "cat "+shellQuote(path))
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("read config: %w", err)
|
||||||
|
}
|
||||||
|
var cfg map[string]any
|
||||||
|
if err := json.Unmarshal([]byte(raw), &cfg); err != nil {
|
||||||
|
return "", fmt.Errorf("parse config.json (should be clean JSON): %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
changed := make([]string, 0, len(updates))
|
||||||
|
for key, val := range updates {
|
||||||
|
setNested(cfg, key, typedValue(val))
|
||||||
|
changed = append(changed, key)
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := json.MarshalIndent(cfg, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("marshal config: %w", err)
|
||||||
|
}
|
||||||
|
if err := m.writeFile(ctx, target, path, out); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
restart := ""
|
||||||
|
if out, err := m.ServiceControl(ctx, alias, product, "restart"); err == nil {
|
||||||
|
restart = out
|
||||||
|
} else {
|
||||||
|
restart = "restart failed: " + err.Error()
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("updated keys: %s\n%s", strings.Join(changed, ", "), restart), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// setNested кладёт значение по точечному пути (создавая промежуточные объекты).
|
||||||
|
func setNested(m map[string]any, path string, val any) {
|
||||||
|
parts := strings.Split(path, ".")
|
||||||
|
cur := m
|
||||||
|
for i := 0; i < len(parts)-1; i++ {
|
||||||
|
next, ok := cur[parts[i]].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
next = map[string]any{}
|
||||||
|
cur[parts[i]] = next
|
||||||
|
}
|
||||||
|
cur = next
|
||||||
|
}
|
||||||
|
cur[parts[len(parts)-1]] = val
|
||||||
|
}
|
||||||
|
|
||||||
|
// typedValue приводит строковое значение из аргумента к «естественному»
|
||||||
|
// JSON-типу (true/false, число, объект/массив), иначе оставляет строкой.
|
||||||
|
// Это сохраняет типы конфига: "5242880" остаётся числом, "true" — булевым,
|
||||||
|
// а произвольный текст — строкой.
|
||||||
|
func typedValue(val any) any {
|
||||||
|
s, ok := val.(string)
|
||||||
|
if !ok || strings.TrimSpace(s) == "" {
|
||||||
|
return val
|
||||||
|
}
|
||||||
|
var v any
|
||||||
|
if err := json.Unmarshal([]byte(s), &v); err == nil {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeFile атомарно записывает данные в файл на машине (tmp + mv).
|
||||||
|
func (m *Manager) writeFile(ctx context.Context, target *Target, path string, data []byte) error {
|
||||||
|
tmp := path + ".tmp"
|
||||||
|
cmd := "sudo -n tee " + shellQuote(tmp) + " >/dev/null && sudo -n mv -f " + shellQuote(tmp) + " " + shellQuote(path)
|
||||||
|
if _, err := m.ExecWithInput(ctx, target, cmd, strings.NewReader(string(data))); err != nil {
|
||||||
|
return fmt.Errorf("write %s: %w", path, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// LicenseApply записывает license.json и проверяет его через /license/check.
|
||||||
|
func (m *Manager) LicenseApply(ctx context.Context, alias, product, rawLicense string) (string, error) {
|
||||||
|
_, target, err := m.mutableTarget(alias)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
l, err := ParseLicense([]byte(rawLicense))
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
path := "/opt/" + product + "/license.json"
|
||||||
|
if err := m.writeFile(ctx, target, path, []byte(rawLicense)); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
ver, err := m.LicenseVerify(ctx, target, product)
|
||||||
|
if err != nil {
|
||||||
|
return "license written; verify: " + err.Error(), nil
|
||||||
|
}
|
||||||
|
return "license written for " + l.Service + "; " + strings.TrimPrefix(ver, "license for "+product+": "), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServiceControl запускает/останавливает/перезапускает службу продукта.
|
||||||
|
// action — start|stop|restart.
|
||||||
|
func (m *Manager) ServiceControl(ctx context.Context, alias, product, action string) (string, error) {
|
||||||
|
_, target, err := m.mutableTarget(alias)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
switch action {
|
||||||
|
case "start", "stop", "restart":
|
||||||
|
default:
|
||||||
|
return "", fmt.Errorf("unknown action %q (expected start|stop|restart)", action)
|
||||||
|
}
|
||||||
|
unit := ServiceUnit(product)
|
||||||
|
out, err := m.runSudo(ctx, target, "systemctl "+action+" "+shellQuote(unit)+" && echo ok || echo failed")
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(out), nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
package lexicomai
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestSetNested(t *testing.T) {
|
||||||
|
m := map[string]any{}
|
||||||
|
setNested(m, "logs.level", "WARN")
|
||||||
|
setNested(m, "device", "cuda:0")
|
||||||
|
setNested(m, "sbs.url", "http://127.0.0.1:6140")
|
||||||
|
|
||||||
|
if m["device"] != "cuda:0" {
|
||||||
|
t.Errorf("device = %v", m["device"])
|
||||||
|
}
|
||||||
|
logs, ok := m["logs"].(map[string]any)
|
||||||
|
if !ok || logs["level"] != "WARN" {
|
||||||
|
t.Errorf("logs.level = %#v", m["logs"])
|
||||||
|
}
|
||||||
|
sbs, ok := m["sbs"].(map[string]any)
|
||||||
|
if !ok || sbs["url"] != "http://127.0.0.1:6140" {
|
||||||
|
t.Errorf("sbs.url = %#v", m["sbs"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfigMutationGate(t *testing.T) {
|
||||||
|
// read_only=true блокирует мутации независимо от allow_mutate.
|
||||||
|
ro := &Config{ReadOnly: boolPtr(true)}
|
||||||
|
tt := &Target{Alias: "a", AllowMutate: true}
|
||||||
|
if err := (&Manager{}).ensureMutationAllowed(ro, tt); err == nil {
|
||||||
|
t.Error("read_only should block mutations")
|
||||||
|
}
|
||||||
|
|
||||||
|
// allow_mutate=false блокирует даже при read_only=false.
|
||||||
|
rw := &Config{ReadOnly: boolPtr(false)}
|
||||||
|
if err := (&Manager{}).ensureMutationAllowed(rw, tt); err != nil {
|
||||||
|
t.Errorf("allow_mutate=true should allow: %v", err)
|
||||||
|
}
|
||||||
|
denied := &Target{Alias: "a", AllowMutate: false}
|
||||||
|
if err := (&Manager{}).ensureMutationAllowed(rw, denied); err == nil {
|
||||||
|
t.Error("allow_mutate=false should block")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func boolPtr(b bool) *bool { return &b }
|
||||||
|
|
||||||
|
func TestTypedValue(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
in string
|
||||||
|
want any
|
||||||
|
}{
|
||||||
|
{"5242880", float64(5242880)}, // число
|
||||||
|
{"true", true}, // булево
|
||||||
|
{"false", false},
|
||||||
|
{"WARN", "WARN"}, // произвольный текст — строка
|
||||||
|
{"", ""}, // пусто — строка
|
||||||
|
{"\"INFO\"", "INFO"},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := typedValue(c.in); got != c.want {
|
||||||
|
t.Errorf("typedValue(%q) = %#v, want %#v", c.in, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// не-строка проходит как есть.
|
||||||
|
if got := typedValue(42); got != 42 {
|
||||||
|
t.Errorf("typedValue(non-string) = %#v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInstallSessionRegistry(t *testing.T) {
|
||||||
|
reg := newInstallSessions()
|
||||||
|
a := reg.add("t", "ups", "release", "7.012", "ups-7.012-release.sh", "/tmp/inst.log")
|
||||||
|
b := reg.add("t", "spr", "release", "", "spr-7.011-release.sh", "/tmp/inst2.log")
|
||||||
|
if a.ID == b.ID {
|
||||||
|
t.Error("session IDs should be unique")
|
||||||
|
}
|
||||||
|
if got, ok := reg.get(a.ID); !ok || got.Product != "ups" {
|
||||||
|
t.Errorf("get(%s) = %+v, ok=%v", a.ID, got, ok)
|
||||||
|
}
|
||||||
|
if _, ok := reg.get("missing"); ok {
|
||||||
|
t.Error("missing session should not exist")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package lexicomai
|
||||||
|
|
||||||
|
import "strings"
|
||||||
|
|
||||||
|
// knownProducts — перечень продуктов компании, которые поддерживает модуль
|
||||||
|
// (наблюдено на сандбоксе-проде и в деплое EDDS). Сторонние/открытые продукты
|
||||||
|
// (vllm), снятые (deprecated), легаси (spr5) и не-сервисы (mocks, wwwroot)
|
||||||
|
// сюда не входят. ses-sip-gateway-api — вторичный, тоже не поддерживается.
|
||||||
|
//
|
||||||
|
// Это имена продуктов, а не адреса: никаких IP/подсетей/VLAN в коде нет.
|
||||||
|
// Список не «жёсткая схема», а валидатор, чтобы модель не обращалась к
|
||||||
|
// несуществующим продуктам (fail-closed).
|
||||||
|
var knownProducts = map[string]struct{}{
|
||||||
|
"acs": {}, "ams": {}, "cls": {}, "crs": {}, "llm": {}, "qas": {},
|
||||||
|
"res": {}, "sbs": {}, "see": {}, "ses": {}, "ses-sip": {},
|
||||||
|
"smc": {}, "spr": {},
|
||||||
|
"tts": {}, "ups": {}, "vds": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultProducts — сводный перечень (для repo_catalog/инвентаря без таргета).
|
||||||
|
func DefaultProducts() []string {
|
||||||
|
return sortedProductNames()
|
||||||
|
}
|
||||||
|
|
||||||
|
// NormalizeProduct приводит имя продукта к каноническому виду (нижний регистр).
|
||||||
|
// Пустое или неизвестное имя — ошибка (fail-closed).
|
||||||
|
func NormalizeProduct(name string) (string, error) {
|
||||||
|
p := strings.ToLower(strings.TrimSpace(name))
|
||||||
|
if p == "" {
|
||||||
|
return "", errProductRequired
|
||||||
|
}
|
||||||
|
if _, ok := knownProducts[p]; !ok {
|
||||||
|
return "", &UnknownProductError{Name: name}
|
||||||
|
}
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServiceUnit возвращает имя systemd-юнита основного сервиса продукта.
|
||||||
|
func ServiceUnit(product string) string {
|
||||||
|
return product + ".service"
|
||||||
|
}
|
||||||
|
|
||||||
|
// sortedProductNames возвращает отсортированный список известных продуктов.
|
||||||
|
func sortedProductNames() []string {
|
||||||
|
names := make([]string, 0, len(knownProducts))
|
||||||
|
for n := range knownProducts {
|
||||||
|
names = append(names, n)
|
||||||
|
}
|
||||||
|
// Простая сортировка — стабильный порядок в выводе.
|
||||||
|
for i := 1; i < len(names); i++ {
|
||||||
|
for j := i; j > 0 && names[j] < names[j-1]; j-- {
|
||||||
|
names[j], names[j-1] = names[j-1], names[j]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return names
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package lexicomai
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestNormalizeProduct(t *testing.T) {
|
||||||
|
if _, err := NormalizeProduct(""); err == nil {
|
||||||
|
t.Error("empty product should error")
|
||||||
|
}
|
||||||
|
if _, err := NormalizeProduct("nope"); err == nil {
|
||||||
|
t.Error("unknown product should error")
|
||||||
|
}
|
||||||
|
p, err := NormalizeProduct("UPS")
|
||||||
|
if err != nil || p != "ups" {
|
||||||
|
t.Errorf("NormalizeProduct(UPS) = %q, %v", p, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServiceUnit(t *testing.T) {
|
||||||
|
if got := ServiceUnit("ups"); got != "ups.service" {
|
||||||
|
t.Errorf("ServiceUnit = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDefaultProductsSorted(t *testing.T) {
|
||||||
|
ps := DefaultProducts()
|
||||||
|
if len(ps) == 0 {
|
||||||
|
t.Fatal("DefaultProducts empty")
|
||||||
|
}
|
||||||
|
for i := 1; i < len(ps); i++ {
|
||||||
|
if ps[i] < ps[i-1] {
|
||||||
|
t.Errorf("not sorted at %d: %q < %q", i, ps[i], ps[i-1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,312 @@
|
|||||||
|
package lexicomai
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Доменные операции M1: инвентарь, статус, база, конфиг, лицензии, логи,
|
||||||
|
// health, диагностика, поиск ошибок и каталог репозитория.
|
||||||
|
//
|
||||||
|
// Методы возвращают готовые человекочитаемые строки (их инструменты просто
|
||||||
|
// оборачивают в textResult), либо доменные ошибки → errorResult.
|
||||||
|
|
||||||
|
// Inventory собирает сводку по всем машинам из конфига: продукт → машина,
|
||||||
|
// состояние службы, наличие лицензии, версия (по возможности).
|
||||||
|
func (m *Manager) Inventory(ctx context.Context) (string, error) {
|
||||||
|
cfg, err := m.resolve()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
var b strings.Builder
|
||||||
|
for _, t := range cfg.Targets {
|
||||||
|
target := t
|
||||||
|
fmt.Fprintf(&b, "== %s (%s) ==\n", target.Alias, target.SSH.Host)
|
||||||
|
if len(target.Products) == 0 {
|
||||||
|
b.WriteString(" (no products declared)\n")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
prods := append([]string(nil), target.Products...)
|
||||||
|
sort.Strings(prods)
|
||||||
|
for _, p := range prods {
|
||||||
|
if env := m.describeProduct(ctx, &target, p); env != "" {
|
||||||
|
b.WriteString(" " + env + "\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
trimmed := strings.TrimRight(b.String(), "\n")
|
||||||
|
return trimmed, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// describeProduct строит одну строку описания продукта на машине.
|
||||||
|
func (m *Manager) describeProduct(ctx context.Context, t *Target, product string) string {
|
||||||
|
env, err := m.Status(ctx, t, product)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Sprintf("%s: %v", product, err)
|
||||||
|
}
|
||||||
|
return env
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status — краткий статус продукта: юнит, порт, движок, venv, активность,
|
||||||
|
// лицензия, версия. Используется и в inventory, и в service_status.
|
||||||
|
func (m *Manager) Status(ctx context.Context, t *Target, product string) (string, error) {
|
||||||
|
info, err := m.ServiceQuery(ctx, t, product)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
state := "inactive"
|
||||||
|
if info.Active {
|
||||||
|
state = "active"
|
||||||
|
}
|
||||||
|
lic := "no license.json"
|
||||||
|
if has, _ := m.fileExists(ctx, t, "/opt/"+product+"/license.json"); has {
|
||||||
|
lic = "license.json present"
|
||||||
|
}
|
||||||
|
port := info.Port
|
||||||
|
if port == 0 {
|
||||||
|
port = t.Port
|
||||||
|
}
|
||||||
|
s := fmt.Sprintf("%s: %s (unit=%s, port=%d, engine=%s, venv=%s, state=%s, %s)",
|
||||||
|
product, state, info.Unit, port, info.Engine, orDash(info.Venv), state, lic)
|
||||||
|
if info.Active && port > 0 {
|
||||||
|
if ver, err := m.Version(ctx, t, port); err == nil && ver != "" {
|
||||||
|
s += ", version=" + ver
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Version пытается получить версию продукта (openapi.json .info.version).
|
||||||
|
func (m *Manager) Version(ctx context.Context, t *Target, port int) (string, error) {
|
||||||
|
oa, err := m.ProbeOpenAPI(ctx, t, port)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if oa.Version != "" {
|
||||||
|
return oa.Version, nil
|
||||||
|
}
|
||||||
|
if oa.Title != "" {
|
||||||
|
return oa.Title, nil
|
||||||
|
}
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// BaseStatus — полнота минимальной базы /opt/pve на машине.
|
||||||
|
func (m *Manager) BaseStatus(ctx context.Context, t *Target) (string, error) {
|
||||||
|
out, err := m.runSudo(ctx, t, "test -d /opt/pve && cat /opt/pve/.version 2>/dev/null; echo ---; ls /opt/pve/bin 2>/dev/null | grep -E 'uvicorn|gunicorn|cythonize|python' | sort")
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
parts := strings.SplitN(out, "---", 2)
|
||||||
|
ver := strings.TrimSpace(parts[0])
|
||||||
|
if strings.Contains(parts[0], "No such") {
|
||||||
|
return "pve base: NOT installed", nil
|
||||||
|
}
|
||||||
|
var bins []string
|
||||||
|
for _, l := range strings.Split(strings.TrimSpace(parts[1]), "\n") {
|
||||||
|
if l = strings.TrimSpace(l); l != "" {
|
||||||
|
bins = append(bins, l)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ver == "" {
|
||||||
|
ver = "(no .version)"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("pve base: present (version=%s, bin=[%s])", ver, strings.Join(bins, ", ")), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConfigGet — читает config.json и сверяет с .example (если есть).
|
||||||
|
func (m *Manager) ConfigGet(ctx context.Context, t *Target, product string) (string, error) {
|
||||||
|
cfg, err := m.runSudo(ctx, t, "cat /opt/"+shellQuote(product)+"/config.json")
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("read config.json: %w", err)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(cfg) == "" {
|
||||||
|
return "", fmt.Errorf("config.json for %s is empty/absent", product)
|
||||||
|
}
|
||||||
|
b := strings.Builder{}
|
||||||
|
fmt.Fprintf(&b, "%s config.json (top-level keys):\n%s\n", product, topKeys(cfg, " "))
|
||||||
|
// Схема .example может содержать //-комментарии — её не парсим, только
|
||||||
|
// сообщаем о наличии.
|
||||||
|
if ex, err := m.runSudo(ctx, t, "cat /opt/"+shellQuote(product)+"/config.json.example"); err == nil && strings.TrimSpace(ex) != "" {
|
||||||
|
fmt.Fprintf(&b, "config.json.example present (%d bytes)\n", len(ex))
|
||||||
|
} else {
|
||||||
|
b.WriteString("no config.json.example\n")
|
||||||
|
}
|
||||||
|
return b.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// LicenseGet — читает license.json продукта и возвращает его содержимое.
|
||||||
|
func (m *Manager) LicenseGet(ctx context.Context, t *Target, product string) (*License, error) {
|
||||||
|
raw, err := m.runSudo(ctx, t, "cat /opt/"+shellQuote(product)+"/license.json")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read license.json: %w", err)
|
||||||
|
}
|
||||||
|
return ParseLicense([]byte(raw))
|
||||||
|
}
|
||||||
|
|
||||||
|
// LicenseVerify — проверяет поля лицензии и, для FastAPI-продуктов, дёргает
|
||||||
|
// /license/check. Возвращает сводку (можно передать прямо в health).
|
||||||
|
func (m *Manager) LicenseVerify(ctx context.Context, t *Target, product string) (string, error) {
|
||||||
|
l, err := m.LicenseGet(ctx, t, product)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
notes := l.VerifyFields(product)
|
||||||
|
var b strings.Builder
|
||||||
|
fmt.Fprintf(&b, "license for %s: service=%s expire=%s seats=%d key=%s\n",
|
||||||
|
product, l.Service, l.Expire, l.Licenses, l.KeyMasked())
|
||||||
|
|
||||||
|
port := t.Port
|
||||||
|
if info, err2 := m.ServiceQuery(ctx, t, product); err2 == nil && info.Port > 0 {
|
||||||
|
port = info.Port
|
||||||
|
}
|
||||||
|
if port > 0 {
|
||||||
|
if ok, code, err := m.ProbeLicenseCheck(ctx, t, port); err == nil {
|
||||||
|
fmt.Fprintf(&b, " /license/check -> %s (http %d)\n", yesNo(ok), code)
|
||||||
|
} else {
|
||||||
|
fmt.Fprintf(&b, " /license/check -> unavailable (%v)\n", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(notes) == 0 {
|
||||||
|
b.WriteString(" fields: OK\n")
|
||||||
|
} else {
|
||||||
|
b.WriteString(" fields warnings: " + strings.Join(notes, "; ") + "\n")
|
||||||
|
}
|
||||||
|
return b.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Logs — tail журнала systemd-юнита продукта (или файлов лога), с фильтром.
|
||||||
|
func (m *Manager) Logs(ctx context.Context, t *Target, product string, tail int, grep string) (string, error) {
|
||||||
|
unit := ServiceUnit(product)
|
||||||
|
if tail <= 0 {
|
||||||
|
tail = 100
|
||||||
|
}
|
||||||
|
cmd := fmt.Sprintf("journalctl --no-pager -u %s -n %d", shellQuote(unit), tail)
|
||||||
|
if grep != "" {
|
||||||
|
cmd += " | grep -i " + shellQuote(grep)
|
||||||
|
}
|
||||||
|
out, err := m.runSudo(ctx, t, cmd)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("journalctl %s: %w", unit, err)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(out) == "" {
|
||||||
|
return fmt.Sprintf("no log lines for %s", unit), nil
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Health — живой статус + openapi + лицензия (для service_health).
|
||||||
|
func (m *Manager) Health(ctx context.Context, t *Target, product string) (string, error) {
|
||||||
|
info, err := m.ServiceQuery(ctx, t, product)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
port := info.Port
|
||||||
|
if port == 0 {
|
||||||
|
port = t.Port
|
||||||
|
}
|
||||||
|
var b strings.Builder
|
||||||
|
fmt.Fprintf(&b, "%s: state=%s engine=%s port=%d venv=%s\n", product, info.SubState, info.Engine, port, orDash(info.Venv))
|
||||||
|
if port > 0 {
|
||||||
|
if oa, err := m.ProbeOpenAPI(ctx, t, port); err == nil {
|
||||||
|
fmt.Fprintf(&b, " openapi: alive=%t version=%q title=%q\n", oa.Alive, oa.Version, oa.Title)
|
||||||
|
} else {
|
||||||
|
fmt.Fprintf(&b, " openapi: not reachable (%v)\n", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
b.WriteString(" port: unknown (can't probe)\n")
|
||||||
|
}
|
||||||
|
if lv, err := m.LicenseVerify(ctx, t, product); err == nil {
|
||||||
|
b.WriteString(" " + lv)
|
||||||
|
} else {
|
||||||
|
fmt.Fprintf(&b, " license: %v\n", err)
|
||||||
|
}
|
||||||
|
return strings.TrimRight(b.String(), "\n"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Diagnose — чек-лист: юнит, процесс, порт, лицензия, база pve.
|
||||||
|
func (m *Manager) Diagnose(ctx context.Context, t *Target, product string) (string, error) {
|
||||||
|
h, err := m.Health(ctx, t, product)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
base, err := m.BaseStatus(ctx, t)
|
||||||
|
if err != nil {
|
||||||
|
base = "pve base: " + err.Error()
|
||||||
|
}
|
||||||
|
return h + "\n" + base, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Errors — грепает журнал/логи по паттернам ошибок за окно.
|
||||||
|
func (m *Manager) Errors(ctx context.Context, t *Target, product string, window, level, pattern string) (string, error) {
|
||||||
|
unit := ServiceUnit(product)
|
||||||
|
if window == "" {
|
||||||
|
window = "1h"
|
||||||
|
}
|
||||||
|
if level == "" {
|
||||||
|
level = "err"
|
||||||
|
}
|
||||||
|
cmd := fmt.Sprintf("journalctl --no-pager -u %s --since %s -p %s", shellQuote(unit), shellQuote(window), shellQuote(level))
|
||||||
|
if pattern != "" {
|
||||||
|
cmd += " | grep -iE " + shellQuote(pattern)
|
||||||
|
} else {
|
||||||
|
cmd += " | grep -iE 'error|traceback|exception|failed'"
|
||||||
|
}
|
||||||
|
out, err := m.runSudo(ctx, t, cmd)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("error scan %s: %w", unit, err)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(out) == "" {
|
||||||
|
return fmt.Sprintf("no matching errors for %s in %s", unit, window), nil
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// fileExists проверяет существование файла на машине (sudo).
|
||||||
|
func (m *Manager) fileExists(ctx context.Context, t *Target, path string) (bool, error) {
|
||||||
|
out, err := m.runSudo(ctx, t, "test -f "+shellQuote(path)+" && echo yes || echo no")
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(out) == "yes", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- помощники форматирования ---
|
||||||
|
|
||||||
|
func orDash(s string) string {
|
||||||
|
if s == "" {
|
||||||
|
return "-"
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func yesNo(b bool) string {
|
||||||
|
if b {
|
||||||
|
return "yes"
|
||||||
|
}
|
||||||
|
return "no"
|
||||||
|
}
|
||||||
|
|
||||||
|
// topKeys извлекает top-level ключи JSON (адаптировано: .example может
|
||||||
|
// содержать комментарии; здесь ожидаем чистый config.json) и возвращает их
|
||||||
|
// строкой через запятую.
|
||||||
|
func topKeys(raw, indent string) string {
|
||||||
|
var keys []string
|
||||||
|
for _, line := range strings.Split(raw, "\n") {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
// key без значения — глубина 1: "...": или "...":{
|
||||||
|
if strings.HasPrefix(line, `"`) {
|
||||||
|
q := strings.Index(line[1:], `"`)
|
||||||
|
if q > 0 {
|
||||||
|
keys = append(keys, line[1:1+q])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(keys) == 0 {
|
||||||
|
return indent + "(could not parse keys)"
|
||||||
|
}
|
||||||
|
return indent + strings.Join(keys, ", ")
|
||||||
|
}
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
package lexicomai
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/cookiejar"
|
||||||
|
"net/url"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// repoFile — элемент листинга каталога SFTPGo web-client (/dirs).
|
||||||
|
type repoFile struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Type int `json:"type"` // 1 = папка, 2 = файл
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
DirPath string `json:"dir_path"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// repoClient — аутентифицированный клиент web-API репозитория.
|
||||||
|
//
|
||||||
|
// Доступ — только web-client (REST /api/v2 и SFTP снаружи закрыты):
|
||||||
|
//
|
||||||
|
// POST {base}/sftpgo/web/client/login (form) → Set-Cookie: jwt
|
||||||
|
//
|
||||||
|
// Креды — из конфига (${VAR}), никогда из кода. Отсутствие кред — fail-closed.
|
||||||
|
func (m *Manager) repoClient(ctx context.Context, cfg *Config) (*http.Client, error) {
|
||||||
|
base := strings.TrimRight(cfg.Repo.Base, "/")
|
||||||
|
if base == "" || cfg.Repo.User == "" || cfg.Repo.Pass == "" {
|
||||||
|
return nil, fmt.Errorf("repo not configured (repo.base/user/pass)")
|
||||||
|
}
|
||||||
|
jar, _ := cookiejar.New(nil)
|
||||||
|
client := &http.Client{Jar: jar}
|
||||||
|
|
||||||
|
loginBody := "username=" + url.QueryEscape(cfg.Repo.User) + "&password=" + url.QueryEscape(cfg.Repo.Pass)
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, base+"/sftpgo/web/client/login", strings.NewReader(loginBody))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("repo login: %w", err)
|
||||||
|
}
|
||||||
|
io.Copy(io.Discard, resp.Body)
|
||||||
|
resp.Body.Close()
|
||||||
|
return client, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// repoList возвращает имена файлов в /repos/<product>/<branch> (только .sh).
|
||||||
|
func (m *Manager) repoList(ctx context.Context, client *http.Client, base, product, branch string) ([]string, error) {
|
||||||
|
p := fmt.Sprintf("/repos/%s/%s", product, branch)
|
||||||
|
listURL := base + "/sftpgo/web/client/dirs?path=" + url.QueryEscape(p)
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, listURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("list %s: http %d", p, resp.StatusCode)
|
||||||
|
}
|
||||||
|
var items []repoFile
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&items); err != nil {
|
||||||
|
return nil, fmt.Errorf("decode %s: %w", p, err)
|
||||||
|
}
|
||||||
|
var names []string
|
||||||
|
for _, it := range items {
|
||||||
|
if it.Type == 2 && strings.HasSuffix(it.Name, ".sh") {
|
||||||
|
names = append(names, it.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return names, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RepoCatalog возвращает версии продукта в ветках release/beta из репозитория.
|
||||||
|
func (m *Manager) RepoCatalog(ctx context.Context, product string) (string, error) {
|
||||||
|
cfg, err := m.resolve()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
client, err := m.repoClient(ctx, cfg)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
base := strings.TrimRight(cfg.Repo.Base, "/")
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
for _, branch := range []string{"release", "beta"} {
|
||||||
|
files, err := m.repoList(ctx, client, base, product, branch)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(&b, "%s: %v\n", branch, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
sort.Strings(files)
|
||||||
|
if len(files) == 0 {
|
||||||
|
fmt.Fprintf(&b, "%s: (empty)\n", branch)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&b, "%s:\n", branch)
|
||||||
|
for _, f := range files {
|
||||||
|
b.WriteString(" " + f + "\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.TrimRight(b.String(), "\n"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RepoResolve находит имя файла дистрибутива для продукта/версии/ветки,
|
||||||
|
// обращаясь к репозиторию. Пустая версия → последняя в ветке.
|
||||||
|
func (m *Manager) RepoResolve(ctx context.Context, product, version, branch string) (string, error) {
|
||||||
|
branch, err := normalizeBranch(branch)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
cfg, err := m.resolve()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
client, err := m.repoClient(ctx, cfg)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
base := strings.TrimRight(cfg.Repo.Base, "/")
|
||||||
|
names, err := m.repoList(ctx, client, base, product, branch)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return ResolveDistro(names, product, version, branch)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RepoStream открывает поток скачивания дистрибутива (web-client download=1).
|
||||||
|
// Вызывающий обязан закрыть поток. Стриминг — без буферизации целиком
|
||||||
|
// (файлы могут быть до ~1.2 ГБ).
|
||||||
|
func (m *Manager) RepoStream(ctx context.Context, product, branch, name string) (io.ReadCloser, error) {
|
||||||
|
cfg, err := m.resolve()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
client, err := m.repoClient(ctx, cfg)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
base := strings.TrimRight(cfg.Repo.Base, "/")
|
||||||
|
p := fmt.Sprintf("/repos/%s/%s/%s", product, branch, name)
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, base+"/sftpgo/web/client/files?path="+url.QueryEscape(p)+"&download=1", nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
io.Copy(io.Discard, resp.Body)
|
||||||
|
resp.Body.Close()
|
||||||
|
return nil, fmt.Errorf("download %s: http %d", p, resp.StatusCode)
|
||||||
|
}
|
||||||
|
return resp.Body, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package lexicomai
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// InstallSession — описатель одной запущенной (или завершённой) установки.
|
||||||
|
// Хранится в Manager, чтобы клиент мог опросить статус по id.
|
||||||
|
type InstallSession struct {
|
||||||
|
ID string
|
||||||
|
Target string // alias машины
|
||||||
|
Product string
|
||||||
|
Branch string
|
||||||
|
Version string
|
||||||
|
File string // имя дистрибутива
|
||||||
|
LogFile string // путь к логу установки на машине
|
||||||
|
Started time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// installSessions — потокобезопасный реестр установок.
|
||||||
|
type installSessions struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
list map[string]*InstallSession
|
||||||
|
seq atomic.Int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func newInstallSessions() *installSessions {
|
||||||
|
return &installSessions{list: make(map[string]*InstallSession)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// add создаёт и регистрирует новую сессию; возвращает её с присвоенным id.
|
||||||
|
func (s *installSessions) add(target, product, branch, version, file, logFile string) *InstallSession {
|
||||||
|
n := s.seq.Add(1)
|
||||||
|
id := fmt.Sprintf("i%d-%d", time.Now().UnixNano(), n)
|
||||||
|
sess := &InstallSession{
|
||||||
|
ID: id,
|
||||||
|
Target: target,
|
||||||
|
Product: product,
|
||||||
|
Branch: branch,
|
||||||
|
Version: version,
|
||||||
|
File: file,
|
||||||
|
LogFile: logFile,
|
||||||
|
Started: time.Now(),
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
s.list[id] = sess
|
||||||
|
return sess
|
||||||
|
}
|
||||||
|
|
||||||
|
// get возвращает сессию по id.
|
||||||
|
func (s *installSessions) get(id string) (*InstallSession, bool) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
sess, ok := s.list[id]
|
||||||
|
return sess, ok
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
package lexicomai
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// shellQuote возвращает сегмент, безопасный для передачи в удалённый shell
|
||||||
|
// (одинарные кавычки + экранирование встроенных '). Пользовательский ввод
|
||||||
|
// никогда не собирается в команду строковой конкатенацией — только через
|
||||||
|
// эту функцию.
|
||||||
|
func shellQuote(s string) string {
|
||||||
|
return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExecResult — результат удалённой команды.
|
||||||
|
type ExecResult struct {
|
||||||
|
// Output — stdout (обрезан по maxBytes при необходимости).
|
||||||
|
Output string
|
||||||
|
// Combined — stdout+stderr (для ошибок и логов).
|
||||||
|
Combined string
|
||||||
|
}
|
||||||
|
|
||||||
|
// newSSHCmd строит выполняемую ssh-команду с нужными опциями подключения.
|
||||||
|
// input — поток stdin на удалённую команду (nil = без ввода). Команда
|
||||||
|
// передаётся одним argv-элементом: ssh склеивает аргументы и отдаёт их
|
||||||
|
// удалённому shell; пользовательские сегменты уже заквотированы вызывающим.
|
||||||
|
func (m *Manager) newSSHCmd(ctx context.Context, target *Target, command string, input io.Reader) (*exec.Cmd, context.CancelFunc) {
|
||||||
|
timeout := m.timeout()
|
||||||
|
if deadline, ok := ctx.Deadline(); ok {
|
||||||
|
if until := time.Until(deadline); until < timeout {
|
||||||
|
timeout = until
|
||||||
|
}
|
||||||
|
}
|
||||||
|
runCtx, cancel := context.WithTimeout(ctx, timeout)
|
||||||
|
|
||||||
|
argv := []string{
|
||||||
|
"-o", "StrictHostKeyChecking=accept-new",
|
||||||
|
"-o", "ConnectTimeout=10",
|
||||||
|
"-o", "BatchMode=yes",
|
||||||
|
"-p", sshPort(target),
|
||||||
|
}
|
||||||
|
if target.SSH.Key != "" {
|
||||||
|
argv = append(argv, "-i", target.SSH.Key)
|
||||||
|
}
|
||||||
|
argv = append(argv, target.SSHAddr(), command)
|
||||||
|
|
||||||
|
cmd := exec.CommandContext(runCtx, "ssh", argv...)
|
||||||
|
cmd.Stdin = input
|
||||||
|
return cmd, cancel
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExecOnTarget выполняет команду на машине через внешний ssh-клиент.
|
||||||
|
//
|
||||||
|
// Команда всегда запускается через sudo -n (non-interactive; пароль не
|
||||||
|
// запрашивается), под пользователем из конфига. Секреты в строку команды не
|
||||||
|
// попадают — только из конфига/окружения. Таймаут ограничен context.
|
||||||
|
//
|
||||||
|
// Потокобезопасно: не хранит состояния, каждый вызов создаёт свой процесс.
|
||||||
|
func (m *Manager) ExecOnTarget(ctx context.Context, target *Target, command string) (*ExecResult, error) {
|
||||||
|
cmd, cancel := m.newSSHCmd(ctx, target, command, nil)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
cmd.Stdout = &stdout
|
||||||
|
cmd.Stderr = &stderr
|
||||||
|
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
// Инфраструктурная ошибка (нет соединения, отказ ключа) — вернём как
|
||||||
|
// есть; инструменты превратят её в non-recoverable (нужен администратор).
|
||||||
|
msg := stderr.String()
|
||||||
|
if msg == "" {
|
||||||
|
msg = err.Error()
|
||||||
|
}
|
||||||
|
return &ExecResult{Output: stdout.String(), Combined: msg}, fmt.Errorf("ssh %s: %w", target.SSHAddr(), err)
|
||||||
|
}
|
||||||
|
return &ExecResult{Output: stdout.String(), Combined: stdout.String() + stderr.String()}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExecWithInput выполняет команду на машине, прогоняя input в её stdin.
|
||||||
|
// Используется для стриминга больших файлов (например дистрибутива) на машину
|
||||||
|
// без буферизации целиком: `sudo tee /path`.
|
||||||
|
func (m *Manager) ExecWithInput(ctx context.Context, target *Target, command string, input io.Reader) (*ExecResult, error) {
|
||||||
|
cmd, cancel := m.newSSHCmd(ctx, target, command, input)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
cmd.Stdout = &stdout
|
||||||
|
cmd.Stderr = &stderr
|
||||||
|
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
msg := stderr.String()
|
||||||
|
if msg == "" {
|
||||||
|
msg = err.Error()
|
||||||
|
}
|
||||||
|
return &ExecResult{Output: stdout.String(), Combined: msg}, fmt.Errorf("ssh %s: %w", target.SSHAddr(), err)
|
||||||
|
}
|
||||||
|
return &ExecResult{Output: stdout.String(), Combined: stdout.String() + stderr.String()}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// runSudo выполняет команду от root (sudo -n) на машине и возвращает stdout.
|
||||||
|
func (m *Manager) runSudo(ctx context.Context, target *Target, command string) (string, error) {
|
||||||
|
res, err := m.ExecOnTarget(ctx, target, "sudo -n "+command)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return res.Output, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// sshPort возвращает SSH-порт таргета (22 по умолчанию).
|
||||||
|
func sshPort(t *Target) string {
|
||||||
|
if t.SSH.Port > 0 {
|
||||||
|
return fmt.Sprintf("%d", t.SSH.Port)
|
||||||
|
}
|
||||||
|
return "22"
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
package lexicomai
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// distroNameRe — имя дистрибутива в репозитории: <product>-<version>-<branch>.sh
|
||||||
|
// (например ups-7.012-release.sh, ses-7.045-beta.sh). Версия может содержать
|
||||||
|
// точки, ветка — release|beta.
|
||||||
|
var distroNameRe = regexp.MustCompile(`^(?P<product>[A-Za-z0-9_-]+)-(?P<version>[0-9][0-9.]*)-(?P<branch>release|beta)\.sh$`)
|
||||||
|
|
||||||
|
// Version — семантическая версия (разбивка по точкам). Сравнение числовое,
|
||||||
|
// поэтому 7.12 > 7.9 (в отличие от лексикографического).
|
||||||
|
type Version []int
|
||||||
|
|
||||||
|
// ParseVersion разбирает строку версии "7.012" в числовые сегменты.
|
||||||
|
// Нечисловой сегмент обрезается (считаем по корректному префиксу).
|
||||||
|
func ParseVersion(s string) Version {
|
||||||
|
parts := strings.Split(s, ".")
|
||||||
|
v := make(Version, 0, len(parts))
|
||||||
|
for _, p := range parts {
|
||||||
|
n, err := strconv.Atoi(strings.TrimSpace(p))
|
||||||
|
if err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
v = append(v, n)
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
// String возвращает канонический вид версии (сегменты через точку).
|
||||||
|
func (v Version) String() string {
|
||||||
|
s := make([]string, len(v))
|
||||||
|
for i, n := range v {
|
||||||
|
s[i] = strconv.Itoa(n)
|
||||||
|
}
|
||||||
|
return strings.Join(s, ".")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compare сравнивает две версии семантически.
|
||||||
|
func CompareVersions(a, b string) int {
|
||||||
|
return ParseVersion(a).Compare(ParseVersion(b))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compare сравнивает две версии посегментно (короткая версия считается меньшей
|
||||||
|
// при равенстве общих сегментов, что соответствует общепринятой семантике).
|
||||||
|
func (v Version) Compare(o Version) int {
|
||||||
|
n := len(v)
|
||||||
|
if len(o) < n {
|
||||||
|
n = len(o)
|
||||||
|
}
|
||||||
|
for i := 0; i < n; i++ {
|
||||||
|
if v[i] < o[i] {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
if v[i] > o[i] {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case len(v) < len(o):
|
||||||
|
return -1
|
||||||
|
case len(v) > len(o):
|
||||||
|
return 1
|
||||||
|
default:
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// distroParts — разобранное имя дистрибутива.
|
||||||
|
type distroParts struct {
|
||||||
|
Product string
|
||||||
|
Version string
|
||||||
|
Branch string
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseDistroName разбирает имя файла дистрибутива.
|
||||||
|
func parseDistroName(name string) (distroParts, error) {
|
||||||
|
m := distroNameRe.FindStringSubmatch(name)
|
||||||
|
if m == nil {
|
||||||
|
return distroParts{}, fmt.Errorf("not a distro name: %q", name)
|
||||||
|
}
|
||||||
|
return distroParts{
|
||||||
|
Product: m[1],
|
||||||
|
Version: m[2],
|
||||||
|
Branch: m[3],
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolveDistro выбирает имя файла дистрибутива из списка кандидатов.
|
||||||
|
//
|
||||||
|
// product — продукт; version — требуемая версия (пустая = последняя в ветке);
|
||||||
|
// branch — release|beta (по умолчанию release). Возвращает имя файла.
|
||||||
|
// Если по условиям кандидатов нет — доменная ошибка (модель может исправить).
|
||||||
|
func ResolveDistro(names []string, product, version, branch string) (string, error) {
|
||||||
|
if branch == "" {
|
||||||
|
branch = "release"
|
||||||
|
}
|
||||||
|
// Собираем кандидаты: продукт + ветка, отсортированные по убыванию версии.
|
||||||
|
var candidates []distroParts
|
||||||
|
for _, n := range names {
|
||||||
|
p, err := parseDistroName(n)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if p.Product == product && p.Branch == branch {
|
||||||
|
candidates = append(candidates, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(candidates) == 0 {
|
||||||
|
return "", fmt.Errorf("no %s distro in %s for %q", product, branch, product)
|
||||||
|
}
|
||||||
|
sort.SliceStable(candidates, func(i, j int) bool {
|
||||||
|
return CompareVersions(candidates[i].Version, candidates[j].Version) > 0
|
||||||
|
})
|
||||||
|
|
||||||
|
if version != "" {
|
||||||
|
for _, c := range candidates {
|
||||||
|
if c.Version == version {
|
||||||
|
return product + "-" + c.Version + "-" + c.Branch + ".sh", nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("version %q of %s not found in %s", version, product, branch)
|
||||||
|
}
|
||||||
|
top := candidates[0]
|
||||||
|
return product + "-" + top.Version + "-" + top.Branch + ".sh", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// normalizeBranch приводит значение ветки к каноническому виду (release/beta).
|
||||||
|
func normalizeBranch(branch string) (string, error) {
|
||||||
|
branch = strings.ToLower(strings.TrimSpace(branch))
|
||||||
|
switch branch {
|
||||||
|
case "", "release":
|
||||||
|
return "release", nil
|
||||||
|
case "beta":
|
||||||
|
return "beta", nil
|
||||||
|
default:
|
||||||
|
return "", fmt.Errorf("unknown branch %q (expected release or beta)", branch)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package lexicomai
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestParseVersion(t *testing.T) {
|
||||||
|
if got := ParseVersion("7.012").String(); got != "7.12" {
|
||||||
|
t.Errorf("ParseVersion(7.012) = %q, want 7.12", got)
|
||||||
|
}
|
||||||
|
if got := ParseVersion("7").String(); got != "7" {
|
||||||
|
t.Errorf("ParseVersion(7) = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCompareVersions(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
a, b string
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{"7.12", "7.9", 1}, // семантически больше (в отличие от лексикографии)
|
||||||
|
{"7.9", "7.12", -1},
|
||||||
|
{"7.5", "7.5", 0},
|
||||||
|
{"7", "7.0", -1}, // короткая версия меньше при равенстве сегментов
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := CompareVersions(c.a, c.b); got != c.want {
|
||||||
|
t.Errorf("Compare(%q,%q) = %d, want %d", c.a, c.b, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseDistroName(t *testing.T) {
|
||||||
|
p, err := parseDistroName("ups-7.012-release.sh")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse error: %v", err)
|
||||||
|
}
|
||||||
|
if p.Product != "ups" || p.Version != "7.012" || p.Branch != "release" {
|
||||||
|
t.Errorf("unexpected parts %+v", p)
|
||||||
|
}
|
||||||
|
if _, err := parseDistroName("readme.txt"); err == nil {
|
||||||
|
t.Error("non-distro name should error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveDistro(t *testing.T) {
|
||||||
|
names := []string{"ups-7.009-release.sh", "ups-7.012-release.sh", "ups-7.005-beta.sh", "spr-7.011-release.sh"}
|
||||||
|
// последняя release
|
||||||
|
got, err := ResolveDistro(names, "ups", "", "release")
|
||||||
|
if err != nil || got != "ups-7.012-release.sh" {
|
||||||
|
t.Errorf("latest release: %q, %v", got, err)
|
||||||
|
}
|
||||||
|
// точная версия
|
||||||
|
got, _ = ResolveDistro(names, "ups", "7.009", "release")
|
||||||
|
if got != "ups-7.009-release.sh" {
|
||||||
|
t.Errorf("exact version: %q", got)
|
||||||
|
}
|
||||||
|
// ветка beta
|
||||||
|
got, _ = ResolveDistro(names, "ups", "", "beta")
|
||||||
|
if got != "ups-7.005-beta.sh" {
|
||||||
|
t.Errorf("beta: %q", got)
|
||||||
|
}
|
||||||
|
// отсутствует
|
||||||
|
if _, err := ResolveDistro(names, "ses", "", "release"); err == nil {
|
||||||
|
t.Error("missing product should error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeBranch(t *testing.T) {
|
||||||
|
if b, _ := normalizeBranch(""); b != "release" {
|
||||||
|
t.Errorf("empty branch = %q", b)
|
||||||
|
}
|
||||||
|
if b, _ := normalizeBranch("BETA"); b != "beta" {
|
||||||
|
t.Errorf("BETA = %q", b)
|
||||||
|
}
|
||||||
|
if _, err := normalizeBranch("dev"); err == nil {
|
||||||
|
t.Error("unknown branch should error")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package tools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"git.totmin.ru/en2zmax/forge-toolkit"
|
||||||
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Адаптеры к forge-toolkit. Локальные имена держим короткими, чтобы объявления
|
||||||
|
// инструментов читались как схема, а не как вызов библиотеки. Если тулкит
|
||||||
|
// сменит сигнатуры — правится только этот файл.
|
||||||
|
|
||||||
|
func schema(props map[string]any, required []string) map[string]any {
|
||||||
|
return toolkit.Schema(props, 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 requestArgs(req *mcp.CallToolRequest) map[string]any { return toolkit.RequestArgs(req) }
|
||||||
|
|
||||||
|
func getString(m map[string]any, key, def string) string { return toolkit.GetString(m, key, def) }
|
||||||
|
|
||||||
|
func requireString(m map[string]any, key string) (string, error) {
|
||||||
|
return toolkit.RequireString(m, key)
|
||||||
|
}
|
||||||
|
|
||||||
|
func getInt(m map[string]any, key string, def int) int { return toolkit.GetInt(m, key, def) }
|
||||||
|
|
||||||
|
func textResult(s string) *mcp.CallToolResult { return toolkit.Text(s) }
|
||||||
|
|
||||||
|
func errorResult(msg string) *mcp.CallToolResult { return toolkit.Error(msg) }
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
package tools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"forge-tools-lexicomai/internal/lexicomai"
|
||||||
|
|
||||||
|
"git.totmin.ru/en2zmax/forge-toolkit"
|
||||||
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||||
|
)
|
||||||
|
|
||||||
|
// registerInstall — мутации (M3–M5): установка, обновление, удаление, база,
|
||||||
|
// конфигурация, лицензия, управление службой. Все через addAction (forge-probe)
|
||||||
|
// → хост применяет политику approval; домен дополнительно проверяет
|
||||||
|
// read_only/allow_mutate.
|
||||||
|
func registerInstall(r *registrar) {
|
||||||
|
r.addAction("service_install",
|
||||||
|
"Установка продукта на машину (асинхронно, возвращает id). Плюс последняя версия в ветке.",
|
||||||
|
schema(map[string]any{
|
||||||
|
"product": strProps("Продукт компании", true, lexicomai.DefaultProducts()...),
|
||||||
|
"alias": strProps("Машина (target alias); пусто = по умолчанию", false),
|
||||||
|
"version": strProps("Версия (например 7.012); пусто = последняя", false),
|
||||||
|
"branch": strProps("release или beta (default release)", false, "release", "beta"),
|
||||||
|
}, []string{"product"}),
|
||||||
|
patternsFor("install"),
|
||||||
|
func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
args := requestArgs(req)
|
||||||
|
p, err := productArg(args)
|
||||||
|
if err != nil {
|
||||||
|
return errorResult(err.Error()), nil
|
||||||
|
}
|
||||||
|
id, err := r.m.Install(ctx, getString(args, "alias", ""), p,
|
||||||
|
getString(args, "version", ""), getString(args, "branch", "release"))
|
||||||
|
if err != nil {
|
||||||
|
return errorResult(err.Error()), nil
|
||||||
|
}
|
||||||
|
return textResult("install started, id=" + id), nil
|
||||||
|
})
|
||||||
|
|
||||||
|
r.add("service_install_status",
|
||||||
|
"Прогресс установки по id (service_install). Read-only.",
|
||||||
|
schema(map[string]any{
|
||||||
|
"alias": strProps("Машина (target alias); пусто = по умолчанию", false),
|
||||||
|
"id": strProps("id установки", true),
|
||||||
|
}, []string{"id"}),
|
||||||
|
func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
args := requestArgs(req)
|
||||||
|
id, err := requireString(args, "id")
|
||||||
|
if err != nil {
|
||||||
|
return errorResult(err.Error()), nil
|
||||||
|
}
|
||||||
|
sess, ok := r.m.Session(id)
|
||||||
|
if !ok {
|
||||||
|
return errorResult("install session " + id + " not found (id is per-process)"), nil
|
||||||
|
}
|
||||||
|
t, err := targetArg(r.m, args)
|
||||||
|
if err != nil {
|
||||||
|
return errorResult(err.Error()), nil
|
||||||
|
}
|
||||||
|
out, err := r.m.InstallStatus(ctx, t, sess)
|
||||||
|
if err != nil {
|
||||||
|
return errorResult(err.Error()), nil
|
||||||
|
}
|
||||||
|
return textResult(out), nil
|
||||||
|
})
|
||||||
|
|
||||||
|
r.addAction("service_uninstall",
|
||||||
|
"Удаление продукта с машины (инсталлятор --uninstall).",
|
||||||
|
schema(map[string]any{
|
||||||
|
"product": strProps("Продукт компании", true, lexicomai.DefaultProducts()...),
|
||||||
|
"alias": strProps("Машина (target alias); пусто = по умолчанию", false),
|
||||||
|
}, []string{"product"}),
|
||||||
|
patternsFor("uninstall"),
|
||||||
|
func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
args := requestArgs(req)
|
||||||
|
p, err := productArg(args)
|
||||||
|
if err != nil {
|
||||||
|
return errorResult(err.Error()), nil
|
||||||
|
}
|
||||||
|
out, err := r.m.Uninstall(ctx, getString(args, "alias", ""), p)
|
||||||
|
if err != nil {
|
||||||
|
return errorResult(err.Error()), nil
|
||||||
|
}
|
||||||
|
return textResult(out), nil
|
||||||
|
})
|
||||||
|
|
||||||
|
r.addAction("service_update",
|
||||||
|
"Обновление продукта до последней версии в ветке (backup инсталлятором).",
|
||||||
|
schema(map[string]any{
|
||||||
|
"product": strProps("Продукт компании", true, lexicomai.DefaultProducts()...),
|
||||||
|
"alias": strProps("Машина (target alias); пусто = по умолчанию", false),
|
||||||
|
"branch": strProps("release или beta (default release)", false, "release", "beta"),
|
||||||
|
}, []string{"product"}),
|
||||||
|
patternsFor("update"),
|
||||||
|
func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
args := requestArgs(req)
|
||||||
|
p, err := productArg(args)
|
||||||
|
if err != nil {
|
||||||
|
return errorResult(err.Error()), nil
|
||||||
|
}
|
||||||
|
id, err := r.m.Install(ctx, getString(args, "alias", ""), p, "", getString(args, "branch", "release"))
|
||||||
|
if err != nil {
|
||||||
|
return errorResult(err.Error()), nil
|
||||||
|
}
|
||||||
|
return textResult("update started, id=" + id), nil
|
||||||
|
})
|
||||||
|
|
||||||
|
r.addAction("base_ensure",
|
||||||
|
"Создание минимальной базы /opt/pve на машине (идемпотентно).",
|
||||||
|
schema(map[string]any{
|
||||||
|
"alias": strProps("Машина (target alias); пусто = по умолчанию", false),
|
||||||
|
}, nil),
|
||||||
|
func(map[string]any) ([]string, []string) { return []string{"lexicomai base"}, nil },
|
||||||
|
func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
args := requestArgs(req)
|
||||||
|
out, err := r.m.BaseEnsure(ctx, getString(args, "alias", ""))
|
||||||
|
if err != nil {
|
||||||
|
return errorResult(err.Error()), nil
|
||||||
|
}
|
||||||
|
return textResult(out), nil
|
||||||
|
})
|
||||||
|
|
||||||
|
r.addAction("service_config_set",
|
||||||
|
"Изменение ключей config.json (точечный путь, например logs.level) + рестарт.",
|
||||||
|
schema(map[string]any{
|
||||||
|
"product": strProps("Продукт компании", true, lexicomai.DefaultProducts()...),
|
||||||
|
"alias": strProps("Машина (target alias); пусто = по умолчанию", false),
|
||||||
|
"key": strProps("Ключ (точечный путь, например logs.level)", true),
|
||||||
|
"value": strProps("Новое значение (строка)", true),
|
||||||
|
}, []string{"product", "key", "value"}),
|
||||||
|
patternsFor("config"),
|
||||||
|
func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
args := requestArgs(req)
|
||||||
|
p, err := productArg(args)
|
||||||
|
if err != nil {
|
||||||
|
return errorResult(err.Error()), nil
|
||||||
|
}
|
||||||
|
key, err := requireString(args, "key")
|
||||||
|
if err != nil {
|
||||||
|
return errorResult(err.Error()), nil
|
||||||
|
}
|
||||||
|
out, err := r.m.ConfigSet(ctx, getString(args, "alias", ""), p, map[string]any{key: getString(args, "value", "")})
|
||||||
|
if err != nil {
|
||||||
|
return errorResult(err.Error()), nil
|
||||||
|
}
|
||||||
|
return textResult(out), nil
|
||||||
|
})
|
||||||
|
|
||||||
|
r.addAction("license_apply",
|
||||||
|
"Запись license.json и проверка через /license/check.",
|
||||||
|
schema(map[string]any{
|
||||||
|
"product": strProps("Продукт компании", true, lexicomai.DefaultProducts()...),
|
||||||
|
"alias": strProps("Машина (target alias); пусто = по умолчанию", false),
|
||||||
|
"license": strProps("JSON лицензии (service/expire/licenses/key)", true),
|
||||||
|
}, []string{"product", "license"}),
|
||||||
|
patternsFor("license"),
|
||||||
|
func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
args := requestArgs(req)
|
||||||
|
p, err := productArg(args)
|
||||||
|
if err != nil {
|
||||||
|
return errorResult(err.Error()), nil
|
||||||
|
}
|
||||||
|
lic, err := requireString(args, "license")
|
||||||
|
if err != nil {
|
||||||
|
return errorResult(err.Error()), nil
|
||||||
|
}
|
||||||
|
out, err := r.m.LicenseApply(ctx, getString(args, "alias", ""), p, lic)
|
||||||
|
if err != nil {
|
||||||
|
return errorResult(err.Error()), nil
|
||||||
|
}
|
||||||
|
return textResult(out), nil
|
||||||
|
})
|
||||||
|
|
||||||
|
r.addAction("service_restart",
|
||||||
|
"Перезапуск службы продукта (systemctl restart).",
|
||||||
|
schema(map[string]any{
|
||||||
|
"product": strProps("Продукт компании", true, lexicomai.DefaultProducts()...),
|
||||||
|
"alias": strProps("Машина (target alias); пусто = по умолчанию", false),
|
||||||
|
}, []string{"product"}),
|
||||||
|
patternsFor("restart"),
|
||||||
|
func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
args := requestArgs(req)
|
||||||
|
p, err := productArg(args)
|
||||||
|
if err != nil {
|
||||||
|
return errorResult(err.Error()), nil
|
||||||
|
}
|
||||||
|
out, err := r.m.ServiceControl(ctx, getString(args, "alias", ""), p, "restart")
|
||||||
|
if err != nil {
|
||||||
|
return errorResult(err.Error()), nil
|
||||||
|
}
|
||||||
|
return textResult(out), nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// patternsFor строит permission-паттерн «lexicomai <action> <product>».
|
||||||
|
func patternsFor(action string) toolkit.PatternsFn {
|
||||||
|
return func(args map[string]any) ([]string, []string) {
|
||||||
|
p := getString(args, "product", "")
|
||||||
|
return []string{"lexicomai " + action + " " + p}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,255 @@
|
|||||||
|
package tools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"forge-tools-lexicomai/internal/lexicomai"
|
||||||
|
|
||||||
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Общие помощники обработчиков: нормализация аргументов product/alias и
|
||||||
|
// превращение доменного значения в результат инструмента.
|
||||||
|
|
||||||
|
// productArg читает обязательный аргумент product и нормализует его
|
||||||
|
// (нижний регистр + проверка, что продукт поддерживается).
|
||||||
|
func productArg(args map[string]any) (string, error) {
|
||||||
|
p, err := requireString(args, "product")
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return lexicomai.NormalizeProduct(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
// targetArg резолвит машину по alias (пустой alias — «по умолчанию»).
|
||||||
|
func targetArg(m *lexicomai.Manager, args map[string]any) (*lexicomai.Target, error) {
|
||||||
|
alias := getString(args, "alias", "")
|
||||||
|
return m.Target(alias)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *registrar) run(ctx context.Context, req *mcp.CallToolRequest, fn func(*lexicomai.Target) (string, error)) (*mcp.CallToolResult, error) {
|
||||||
|
args := requestArgs(req)
|
||||||
|
t, err := targetArg(r.m, args)
|
||||||
|
if err != nil {
|
||||||
|
return errorResult(err.Error()), nil
|
||||||
|
}
|
||||||
|
out, err := fn(t)
|
||||||
|
if err != nil {
|
||||||
|
return errorResult(err.Error()), nil // доменная ошибка → модели (recoverable)
|
||||||
|
}
|
||||||
|
return textResult(out), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// registerInventory — инвентарь и статус (products_list, service_status,
|
||||||
|
// repo_catalog, base_status).
|
||||||
|
func registerInventory(r *registrar) {
|
||||||
|
r.add("products_list",
|
||||||
|
"Инвентарь продуктов по всем машинам конфига: продукт → машина, состояние службы, порт, движок, лицензия, версия. Read-only.",
|
||||||
|
schema(map[string]any{}, nil),
|
||||||
|
func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
out, err := r.m.Inventory(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return errorResult(err.Error()), nil
|
||||||
|
}
|
||||||
|
return textResult(out), nil
|
||||||
|
})
|
||||||
|
|
||||||
|
r.add("service_status",
|
||||||
|
"Краткий статус продукта на машине: unit, порт, движок, venv, состояние, лицензия, версия. Read-only.",
|
||||||
|
schema(map[string]any{
|
||||||
|
"product": strProps("Продукт компании (например ups, spr, cls)", true, lexicomai.DefaultProducts()...),
|
||||||
|
"alias": strProps("Машина (target alias); пусто = по умолчанию", false),
|
||||||
|
}, []string{"product"}),
|
||||||
|
func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
args := requestArgs(req)
|
||||||
|
p, err := productArg(args)
|
||||||
|
if err != nil {
|
||||||
|
return errorResult(err.Error()), nil
|
||||||
|
}
|
||||||
|
return r.run(ctx, req, func(t *lexicomai.Target) (string, error) {
|
||||||
|
return r.m.Status(ctx, t, p)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
r.add("repo_catalog",
|
||||||
|
"Список версий продукта в репозитории (ветки release/beta). Read-only.",
|
||||||
|
schema(map[string]any{
|
||||||
|
"product": strProps("Продукт компании", true, lexicomai.DefaultProducts()...),
|
||||||
|
}, []string{"product"}),
|
||||||
|
func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
args := requestArgs(req)
|
||||||
|
p, err := productArg(args)
|
||||||
|
if err != nil {
|
||||||
|
return errorResult(err.Error()), nil
|
||||||
|
}
|
||||||
|
out, err := r.m.RepoCatalog(ctx, p)
|
||||||
|
if err != nil {
|
||||||
|
return errorResult(err.Error()), nil
|
||||||
|
}
|
||||||
|
return textResult(out), nil
|
||||||
|
})
|
||||||
|
|
||||||
|
r.add("base_status",
|
||||||
|
"Полнота минимальной базы /opt/pve на машине (.version, bin). Read-only.",
|
||||||
|
schema(map[string]any{
|
||||||
|
"alias": strProps("Машина (target alias); пусто = по умолчанию", false),
|
||||||
|
}, nil),
|
||||||
|
func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
return r.run(ctx, req, func(t *lexicomai.Target) (string, error) {
|
||||||
|
return r.m.BaseStatus(ctx, t)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// registerConfig — конфигурация сервиса (service_config_get).
|
||||||
|
func registerConfig(r *registrar) {
|
||||||
|
r.add("service_config_get",
|
||||||
|
"Читает config.json продукта и сообщает top-level ключи (+ наличие .example). Read-only.",
|
||||||
|
schema(map[string]any{
|
||||||
|
"product": strProps("Продукт компании", true, lexicomai.DefaultProducts()...),
|
||||||
|
"alias": strProps("Машина (target alias); пусто = по умолчанию", false),
|
||||||
|
}, []string{"product"}),
|
||||||
|
func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
args := requestArgs(req)
|
||||||
|
p, err := productArg(args)
|
||||||
|
if err != nil {
|
||||||
|
return errorResult(err.Error()), nil
|
||||||
|
}
|
||||||
|
return r.run(ctx, req, func(t *lexicomai.Target) (string, error) {
|
||||||
|
return r.m.ConfigGet(ctx, t, p)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// registerLicense — лицензии (license_get, license_verify).
|
||||||
|
func registerLicense(r *registrar) {
|
||||||
|
r.add("license_get",
|
||||||
|
"Содержимое license.json продукта (service, expire, licenses, key). Read-only.",
|
||||||
|
schema(map[string]any{
|
||||||
|
"product": strProps("Продукт компании", true, lexicomai.DefaultProducts()...),
|
||||||
|
"alias": strProps("Машина (target alias); пусто = по умолчанию", false),
|
||||||
|
}, []string{"product"}),
|
||||||
|
func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
args := requestArgs(req)
|
||||||
|
p, err := productArg(args)
|
||||||
|
if err != nil {
|
||||||
|
return errorResult(err.Error()), nil
|
||||||
|
}
|
||||||
|
return r.run(ctx, req, func(t *lexicomai.Target) (string, error) {
|
||||||
|
l, err := r.m.LicenseGet(ctx, t, p)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return licenseText(l), nil
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
r.add("license_verify",
|
||||||
|
"Проверка лицензии: поля (service/expire/занятость) + GET /license/check для FastAPI-продуктов. Read-only.",
|
||||||
|
schema(map[string]any{
|
||||||
|
"product": strProps("Продукт компании", true, lexicomai.DefaultProducts()...),
|
||||||
|
"alias": strProps("Машина (target alias); пусто = по умолчанию", false),
|
||||||
|
}, []string{"product"}),
|
||||||
|
func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
args := requestArgs(req)
|
||||||
|
p, err := productArg(args)
|
||||||
|
if err != nil {
|
||||||
|
return errorResult(err.Error()), nil
|
||||||
|
}
|
||||||
|
return r.run(ctx, req, func(t *lexicomai.Target) (string, error) {
|
||||||
|
return r.m.LicenseVerify(ctx, t, p)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// licenseText форматирует лицензию в читаемый вид (ключ — с маской).
|
||||||
|
func licenseText(l *lexicomai.License) string {
|
||||||
|
return "license: service=" + l.Service + " expire=" + l.Expire +
|
||||||
|
" seats=" + itoa(l.Licenses) + " key=" + l.KeyMasked()
|
||||||
|
}
|
||||||
|
|
||||||
|
func itoa(n int) string {
|
||||||
|
return fmt.Sprintf("%d", n)
|
||||||
|
}
|
||||||
|
|
||||||
|
// registerHealth — логи, health, диагностика, поиск ошибок.
|
||||||
|
func registerHealth(r *registrar) {
|
||||||
|
r.add("service_logs",
|
||||||
|
"Хвост журнала systemd-юнита продукта (опц. фильтр grep). Read-only.",
|
||||||
|
schema(map[string]any{
|
||||||
|
"product": strProps("Продукт компании", true, lexicomai.DefaultProducts()...),
|
||||||
|
"alias": strProps("Машина (target alias); пусто = по умолчанию", false),
|
||||||
|
"tail": intProps("Число строк (default 100)", false),
|
||||||
|
"grep": strProps("Подстрока-фильтр (по умолчанию нет)", false),
|
||||||
|
}, []string{"product"}),
|
||||||
|
func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
args := requestArgs(req)
|
||||||
|
p, err := productArg(args)
|
||||||
|
if err != nil {
|
||||||
|
return errorResult(err.Error()), nil
|
||||||
|
}
|
||||||
|
tail := getInt(args, "tail", 100)
|
||||||
|
grep := getString(args, "grep", "")
|
||||||
|
return r.run(ctx, req, func(t *lexicomai.Target) (string, error) {
|
||||||
|
return r.m.Logs(ctx, t, p, tail, grep)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
r.add("service_health",
|
||||||
|
"Живой статус + /openapi.json (жив/версия) + /license/check. Read-only.",
|
||||||
|
schema(map[string]any{
|
||||||
|
"product": strProps("Продукт компании", true, lexicomai.DefaultProducts()...),
|
||||||
|
"alias": strProps("Машина (target alias); пусто = по умолчанию", false),
|
||||||
|
}, []string{"product"}),
|
||||||
|
func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
args := requestArgs(req)
|
||||||
|
p, err := productArg(args)
|
||||||
|
if err != nil {
|
||||||
|
return errorResult(err.Error()), nil
|
||||||
|
}
|
||||||
|
return r.run(ctx, req, func(t *lexicomai.Target) (string, error) {
|
||||||
|
return r.m.Health(ctx, t, p)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
r.add("service_diagnose",
|
||||||
|
"Чек-лист: юнит, процесс, порт, лицензия, база /opt/pve. Read-only.",
|
||||||
|
schema(map[string]any{
|
||||||
|
"product": strProps("Продукт компании", true, lexicomai.DefaultProducts()...),
|
||||||
|
"alias": strProps("Машина (target alias); пусто = по умолчанию", false),
|
||||||
|
}, []string{"product"}),
|
||||||
|
func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
args := requestArgs(req)
|
||||||
|
p, err := productArg(args)
|
||||||
|
if err != nil {
|
||||||
|
return errorResult(err.Error()), nil
|
||||||
|
}
|
||||||
|
return r.run(ctx, req, func(t *lexicomai.Target) (string, error) {
|
||||||
|
return r.m.Diagnose(ctx, t, p)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
r.add("errors_search",
|
||||||
|
"Поиск ошибок в журнале продукта за окно (по умолчанию -p err, окно 1h). Read-only.",
|
||||||
|
schema(map[string]any{
|
||||||
|
"product": strProps("Продукт компании", true, lexicomai.DefaultProducts()...),
|
||||||
|
"alias": strProps("Машина (target alias); пусто = по умолчанию", false),
|
||||||
|
"window": strProps("Окно --since, например 1h/24h (default 1h)", false),
|
||||||
|
"level": strProps("Приоритет -p (default err)", false),
|
||||||
|
"pattern": strProps("Regex-фильтр ошибок (default: error|traceback|exception|failed)", false),
|
||||||
|
}, []string{"product"}),
|
||||||
|
func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
args := requestArgs(req)
|
||||||
|
p, err := productArg(args)
|
||||||
|
if err != nil {
|
||||||
|
return errorResult(err.Error()), nil
|
||||||
|
}
|
||||||
|
window := getString(args, "window", "1h")
|
||||||
|
level := getString(args, "level", "err")
|
||||||
|
pattern := getString(args, "pattern", "")
|
||||||
|
return r.run(ctx, req, func(t *lexicomai.Target) (string, error) {
|
||||||
|
return r.m.Errors(ctx, t, p, window, level, pattern)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package tools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"forge-tools-lexicomai/internal/lexicomai"
|
||||||
|
|
||||||
|
"git.totmin.ru/en2zmax/forge-toolkit"
|
||||||
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||||
|
)
|
||||||
|
|
||||||
|
// registrar — тонкая обёртка регистрации: держит сервер и Manager и даёт
|
||||||
|
// доменам лаконичный API. Все схемы — ручные (объявлены явно).
|
||||||
|
type registrar struct {
|
||||||
|
s *mcp.Server
|
||||||
|
m *lexicomai.Manager
|
||||||
|
}
|
||||||
|
|
||||||
|
// add регистрирует read-only/безопасный инструмент (probe не требуется).
|
||||||
|
// inputSchema — уже собранная схема (см. schema(...) с required).
|
||||||
|
func (r *registrar) add(name, desc string, inputSchema map[string]any, h mcp.ToolHandler) {
|
||||||
|
r.s.AddTool(&mcp.Tool{
|
||||||
|
Name: name,
|
||||||
|
Description: desc,
|
||||||
|
InputSchema: inputSchema,
|
||||||
|
}, h)
|
||||||
|
}
|
||||||
|
|
||||||
|
// addAction регистрирует side-effecting инструмент через permission-паттерны:
|
||||||
|
// хост узнаёт паттерн ДО выполнения и применяет политику одобрения. Пока не
|
||||||
|
// используется (M1 — только чтение), но готово для установки/конфига/лицензии
|
||||||
|
// в следующих итерациях.
|
||||||
|
func (r *registrar) addAction(name, desc string, inputSchema map[string]any,
|
||||||
|
patterns toolkit.PatternsFn, h mcp.ToolHandler) {
|
||||||
|
toolkit.RegisterPatternTool(r.s, &mcp.Tool{
|
||||||
|
Name: name,
|
||||||
|
Description: desc,
|
||||||
|
InputSchema: inputSchema,
|
||||||
|
}, patterns, h)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisterAll — единая точка регистрации инструментов модуля.
|
||||||
|
//
|
||||||
|
// Каждый следующий функциональный блок добавляется сюда одной строкой,
|
||||||
|
// поэтому каркас разрастается предсказуемо.
|
||||||
|
func RegisterAll(s *mcp.Server, m *lexicomai.Manager) {
|
||||||
|
r := ®istrar{s: s, m: m}
|
||||||
|
|
||||||
|
registerInventory(r)
|
||||||
|
registerConfig(r)
|
||||||
|
registerLicense(r)
|
||||||
|
registerHealth(r)
|
||||||
|
registerInstall(r)
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
// Command forge-tools-lexicomai — MCP-сервер (stdio) для управления
|
||||||
|
// жизненным циклом продуктов Lexicom AI на выделенных машинах: инвентарь,
|
||||||
|
// статус, конфигурация, лицензии, health и диагностика.
|
||||||
|
//
|
||||||
|
// Архитектура такая же, как у остальных встроенных MCP-серверов: stdio-only,
|
||||||
|
// одиночный потокобезопасный Manager, ручные JSON-схемы, --health, graceful
|
||||||
|
// shutdown и live-reload конфига. Модуль намеренно НЕ дублирует удалённый
|
||||||
|
// exec/файлы/гипервизор/HTTP (это зоны других серверов): здесь только
|
||||||
|
// доменная семантика продукта, а SSH-транспорт к машинам — внутренняя
|
||||||
|
// «сантехника».
|
||||||
|
//
|
||||||
|
// Каноническое имя аддона: module forge-tools-lexicomai, сервер «lexicomai».
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"forge-tools-lexicomai/internal/lexicomai"
|
||||||
|
"forge-tools-lexicomai/internal/tools"
|
||||||
|
|
||||||
|
"git.totmin.ru/en2zmax/forge-toolkit"
|
||||||
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
// --health: дешёвая проверка живости без чтения конфига и подключений.
|
||||||
|
if toolkit.Health() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
configPath := flag.String("config", "",
|
||||||
|
"путь к config.json (фолбэк, если нет per-agent <FORGE_TENANT_CONFIG>/lexicomai.json)")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
// Конфиг: per-agent файл (live-reload) → статический -config. Источник,
|
||||||
|
// приоритет и фолбэк инкапсулированы в lexicomai.Loader.
|
||||||
|
loader := lexicomai.NewLoader(os.Getenv("FORGE_TENANT_CONFIG"), *configPath)
|
||||||
|
mgr := lexicomai.NewManager(loader)
|
||||||
|
defer mgr.Close()
|
||||||
|
|
||||||
|
if err := toolkit.Run("lexicomai", func(s *mcp.Server) {
|
||||||
|
tools.RegisterAll(s, mgr)
|
||||||
|
}); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "forge-tools-lexicomai: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user