commit 4beb4b55d3ad1acfd6fe1500d3586e496c5ec7a4 Author: Maksim Totmin Date: Thu Oct 1 09:50:59 2026 +0700 first commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b8af0d9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +# Секреты и реальные подключения — никогда в git. +*.local.json +.env +*.key +id_ed25519* + +# Собранный бинарь — артефакт сборки, не в репо. +/forge-tools-lexicomai diff --git a/README.md b/README.md new file mode 100644 index 0000000..0f4b006 --- /dev/null +++ b/README.md @@ -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-проверка на боевых машинах — только на стейджинге. diff --git a/config.json b/config.json new file mode 100644 index 0000000..4fedb9d --- /dev/null +++ b/config.json @@ -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}" } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..1e98623 --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..577b34d --- /dev/null +++ b/go.sum @@ -0,0 +1,26 @@ +git.totmin.ru/en2zmax/forge-toolkit v0.1.0 h1:d4p1mDzPwG/CuehTXVlyeT0vAWkQmzHG83NLmqUxkAo= +git.totmin.ru/en2zmax/forge-toolkit v0.1.0/go.mod h1:4LVtO/yq0SsTP6opDYS32JiDNC/tBBIAwGLGGw/D88Q= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0= +github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= +github.com/modelcontextprotocol/go-sdk v1.7.0 h1:yqjY2dsbKAC0LSuWZVBMrHgiG8ukXv6NRo0JiALay44= +github.com/modelcontextprotocol/go-sdk v1.7.0/go.mod h1:dL7u98E/zjJTGzEq+j30jQ8K2k1mb6LeAH4inEcSGts= +github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc= +github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg= +github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0= +github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0= +github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= +github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= +golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= +golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= +golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= +golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= diff --git a/internal/lexicomai/config.go b/internal/lexicomai/config.go new file mode 100644 index 0000000..255a4ba --- /dev/null +++ b/internal/lexicomai/config.go @@ -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 конфигурация модуля. Файл-шаблон лежит в репозитории, +// реальные значения приходят из /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 /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) } diff --git a/internal/lexicomai/config_test.go b/internal/lexicomai/config_test.go new file mode 100644 index 0000000..083be71 --- /dev/null +++ b/internal/lexicomai/config_test.go @@ -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) + } +} diff --git a/internal/lexicomai/discover.go b/internal/lexicomai/discover.go new file mode 100644 index 0000000..f037617 --- /dev/null +++ b/internal/lexicomai/discover.go @@ -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 "" +} diff --git a/internal/lexicomai/discover_test.go b/internal/lexicomai/discover_test.go new file mode 100644 index 0000000..6aad810 --- /dev/null +++ b/internal/lexicomai/discover_test.go @@ -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) + } +} diff --git a/internal/lexicomai/errors.go b/internal/lexicomai/errors.go new file mode 100644 index 0000000..e8cd4ae --- /dev/null +++ b/internal/lexicomai/errors.go @@ -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) +} diff --git a/internal/lexicomai/install.go b/internal/lexicomai/install.go new file mode 100644 index 0000000..a2dd91c --- /dev/null +++ b/internal/lexicomai/install.go @@ -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 "" +} diff --git a/internal/lexicomai/license.go b/internal/lexicomai/license.go new file mode 100644 index 0000000..ea90826 --- /dev/null +++ b/internal/lexicomai/license.go @@ -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:] +} diff --git a/internal/lexicomai/license_test.go b/internal/lexicomai/license_test.go new file mode 100644 index 0000000..d017fdc --- /dev/null +++ b/internal/lexicomai/license_test.go @@ -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") + } +} diff --git a/internal/lexicomai/manager.go b/internal/lexicomai/manager.go new file mode 100644 index 0000000..cfc8ba3 --- /dev/null +++ b/internal/lexicomai/manager.go @@ -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 +} diff --git a/internal/lexicomai/mutate.go b/internal/lexicomai/mutate.go new file mode 100644 index 0000000..df3f38d --- /dev/null +++ b/internal/lexicomai/mutate.go @@ -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 +} diff --git a/internal/lexicomai/mutate_test.go b/internal/lexicomai/mutate_test.go new file mode 100644 index 0000000..49b9cc1 --- /dev/null +++ b/internal/lexicomai/mutate_test.go @@ -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") + } +} diff --git a/internal/lexicomai/products.go b/internal/lexicomai/products.go new file mode 100644 index 0000000..b5d5d68 --- /dev/null +++ b/internal/lexicomai/products.go @@ -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 +} diff --git a/internal/lexicomai/products_test.go b/internal/lexicomai/products_test.go new file mode 100644 index 0000000..5a49663 --- /dev/null +++ b/internal/lexicomai/products_test.go @@ -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]) + } + } +} diff --git a/internal/lexicomai/remote.go b/internal/lexicomai/remote.go new file mode 100644 index 0000000..8b18875 --- /dev/null +++ b/internal/lexicomai/remote.go @@ -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, ", ") +} diff --git a/internal/lexicomai/repo.go b/internal/lexicomai/repo.go new file mode 100644 index 0000000..6806ee9 --- /dev/null +++ b/internal/lexicomai/repo.go @@ -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// (только .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 +} diff --git a/internal/lexicomai/sessions.go b/internal/lexicomai/sessions.go new file mode 100644 index 0000000..8ca1bb3 --- /dev/null +++ b/internal/lexicomai/sessions.go @@ -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 +} diff --git a/internal/lexicomai/ssh.go b/internal/lexicomai/ssh.go new file mode 100644 index 0000000..a0fb241 --- /dev/null +++ b/internal/lexicomai/ssh.go @@ -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" +} diff --git a/internal/lexicomai/version.go b/internal/lexicomai/version.go new file mode 100644 index 0000000..585305d --- /dev/null +++ b/internal/lexicomai/version.go @@ -0,0 +1,144 @@ +package lexicomai + +import ( + "fmt" + "regexp" + "sort" + "strconv" + "strings" +) + +// distroNameRe — имя дистрибутива в репозитории: --.sh +// (например ups-7.012-release.sh, ses-7.045-beta.sh). Версия может содержать +// точки, ветка — release|beta. +var distroNameRe = regexp.MustCompile(`^(?P[A-Za-z0-9_-]+)-(?P[0-9][0-9.]*)-(?Prelease|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) + } +} diff --git a/internal/lexicomai/version_test.go b/internal/lexicomai/version_test.go new file mode 100644 index 0000000..f5ff424 --- /dev/null +++ b/internal/lexicomai/version_test.go @@ -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") + } +} diff --git a/internal/tools/adapters.go b/internal/tools/adapters.go new file mode 100644 index 0000000..78306f4 --- /dev/null +++ b/internal/tools/adapters.go @@ -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) } diff --git a/internal/tools/install.go b/internal/tools/install.go new file mode 100644 index 0000000..102bd7b --- /dev/null +++ b/internal/tools/install.go @@ -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 ». +func patternsFor(action string) toolkit.PatternsFn { + return func(args map[string]any) ([]string, []string) { + p := getString(args, "product", "") + return []string{"lexicomai " + action + " " + p}, nil + } +} diff --git a/internal/tools/read.go b/internal/tools/read.go new file mode 100644 index 0000000..8870042 --- /dev/null +++ b/internal/tools/read.go @@ -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) + }) + }) +} diff --git a/internal/tools/registry.go b/internal/tools/registry.go new file mode 100644 index 0000000..8080e48 --- /dev/null +++ b/internal/tools/registry.go @@ -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) +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..cf6f1d1 --- /dev/null +++ b/main.go @@ -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 /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) + } +}