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