227 lines
9.5 KiB
Go
227 lines
9.5 KiB
Go
// Package lexicomai — доменный слой MCP-модуля forge-tools/lexicomai.
|
|
//
|
|
// Модуль управляет жизненным циклом продуктов Lexicom AI, развёрнутых на
|
|
// выделенных машинах: инвентарь, статус, конфигурация, лицензии и диагностика.
|
|
// Он намеренно НЕ дублирует generic-возможности других forge-tools (ssh —
|
|
// удалённый exec, filesystem — локальные файлы, proxmox — гипервизор,
|
|
// web — HTTP): здесь только доменная семантика продукта (установка/конфиг/
|
|
// лицензия/health/ошибки), а SSH-транспорт — внутренняя «сантехника».
|
|
//
|
|
// Пакет ничего не знает про MCP и SDK: это чистая доменная логика, которую
|
|
// можно тестировать без сети и без SDK.
|
|
package lexicomai
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"git.totmin.ru/en2zmax/forge-toolkit"
|
|
"git.totmin.ru/en2zmax/forge-toolkit/configreload"
|
|
)
|
|
|
|
// Значения по умолчанию. Принцип fail-closed: отсутствие обязательных полей —
|
|
// ошибка, а не «подозрительный» дефолт.
|
|
const (
|
|
tenantConfigFileName = "lexicomai.json"
|
|
tenantConfigDirEnv = "FORGE_TENANT_CONFIG"
|
|
|
|
// DefaultTimeoutSeconds — таймаут одной SSH-команды/HTTP-пробы. Инструменты
|
|
// обязаны завершаться за конечное время, чтобы не блокировать агента.
|
|
DefaultTimeoutSeconds = 15
|
|
// DefaultMaxOutputBytes — кап тела ответа/вывода, чтобы не отдавать модели
|
|
// гигантские результаты; большие значения обрезаются с флагом *_truncated.
|
|
DefaultMaxOutputBytes = 4 << 20 // 4 MiB
|
|
)
|
|
|
|
// SSHTarget — параметры подключения к управляемой машине. Хост, пользователь
|
|
// и ключ задаёт оператор (в per-agent конфиге), модель их не вводит.
|
|
//
|
|
// Никакие адреса/подсети/VLAN в код не зашиты: всё приходит из конфига.
|
|
type SSHTarget struct {
|
|
Host string `json:"host"`
|
|
User string `json:"user"`
|
|
Key string `json:"key"`
|
|
Port int `json:"port"` // SSH-порт; 0 = 22.
|
|
}
|
|
|
|
// Target — одна управляемая машина (alias) и продукты, которые на ней живут.
|
|
//
|
|
// Alias — стабильный идентификатор, на который ссылается модель
|
|
// («платформа объявляет, модель ссылается»). Products — перечень продуктов
|
|
// на машине (контролируется оператором). Port — опциональный порт продукта;
|
|
// если пуст — порт определяется на лету из systemd-юнита/юнитов (не хардкод).
|
|
type Target struct {
|
|
Alias string `json:"alias"`
|
|
SSH SSHTarget `json:"ssh"`
|
|
Products []string `json:"products"`
|
|
// Port — порт основного продукта (удобно, когда их несколько и нужен явный
|
|
// выбор); по пустому значению порт берётся из службы продукта.
|
|
Port int `json:"port"`
|
|
// AllowMutate — разрешены ли мутации на этой машине. Второй слой защиты
|
|
// поверх approval: fail-closed (false = только чтение).
|
|
AllowMutate bool `json:"allow_mutate"`
|
|
}
|
|
|
|
// RepoConfig — доступ к репозиторию дистрибутивов (SFTPGo web-client).
|
|
//
|
|
// REST /api/v2 и SFTP снаружи не экспонированы, поэтому используется только
|
|
// web-client API; креды приходят из ${VAR}/*.local.json, не из кода.
|
|
type RepoConfig struct {
|
|
Base string `json:"base"`
|
|
User string `json:"user"`
|
|
Pass string `json:"pass"`
|
|
}
|
|
|
|
// Config — per-agent конфигурация модуля. Файл-шаблон лежит в репозитории,
|
|
// реальные значения приходят из <FORGE_TENANT_CONFIG>/lexicomai.json
|
|
// (live-reload) или из статического -config как фолбэк.
|
|
type Config struct {
|
|
// Targets — управляемые машины. Пустой список = fail-closed (работать не с чем).
|
|
Targets []Target `json:"targets"`
|
|
// Default — alias машины по умолчанию.
|
|
Default string `json:"default"`
|
|
// ReadOnly — полный запрет мутаций (в доп. к allow_mutate/approval).
|
|
ReadOnly *bool `json:"read_only"`
|
|
// TimeoutSeconds — таймаут SSH-команд и HTTP-проб.
|
|
TimeoutSeconds int `json:"timeout_seconds"`
|
|
// MaxOutputBytes — кап результата.
|
|
MaxOutputBytes int `json:"max_output_bytes"`
|
|
// Repo — доступ к репозиторию.
|
|
Repo RepoConfig `json:"repo"`
|
|
}
|
|
|
|
// ParseConfig разбирает сырые байты конфига: раскрывает ${VAR}, парсит JSON,
|
|
// применяет дефолты и валидирует. Неразрешённая переменная раскрывается в
|
|
// пустую строку и затем отвергается валидацией (fail-closed).
|
|
func ParseConfig(data []byte) (*Config, error) {
|
|
expanded := toolkit.Expand(data)
|
|
|
|
var cfg Config
|
|
if err := json.Unmarshal(expanded, &cfg); err != nil {
|
|
return nil, fmt.Errorf("parse config: %w", err)
|
|
}
|
|
|
|
if len(cfg.Targets) == 0 {
|
|
return nil, fmt.Errorf("config: targets is required (empty list — fail-closed)")
|
|
}
|
|
for i := range cfg.Targets {
|
|
t := &cfg.Targets[i]
|
|
if strings.TrimSpace(t.Alias) == "" {
|
|
return nil, fmt.Errorf("config: targets[%d].alias is required", i)
|
|
}
|
|
if strings.TrimSpace(t.SSH.Host) == "" {
|
|
return nil, fmt.Errorf("config: targets[%d].ssh.host is required", i)
|
|
}
|
|
if strings.TrimSpace(t.SSH.User) == "" {
|
|
return nil, fmt.Errorf("config: targets[%d].ssh.user is required", i)
|
|
}
|
|
}
|
|
if cfg.Default == "" {
|
|
cfg.Default = cfg.Targets[0].Alias
|
|
}
|
|
if cfg.TimeoutSeconds <= 0 {
|
|
cfg.TimeoutSeconds = DefaultTimeoutSeconds
|
|
}
|
|
if cfg.MaxOutputBytes <= 0 {
|
|
cfg.MaxOutputBytes = DefaultMaxOutputBytes
|
|
}
|
|
return &cfg, nil
|
|
}
|
|
|
|
// Timeout возвращает таймаут одной операции как time.Duration.
|
|
func (c *Config) Timeout() time.Duration {
|
|
return time.Duration(c.TimeoutSeconds) * time.Second
|
|
}
|
|
|
|
// IsReadOnly сообщает, находится ли модуль в режиме только чтения.
|
|
func (c *Config) IsReadOnly() bool {
|
|
return c.ReadOnly != nil && *c.ReadOnly
|
|
}
|
|
|
|
// Target возвращает машину по alias; пустой alias — «по умолчанию».
|
|
func (c *Config) Target(alias string) (*Target, error) {
|
|
if alias == "" {
|
|
alias = c.Default
|
|
}
|
|
for i := range c.Targets {
|
|
if c.Targets[i].Alias == alias {
|
|
return &c.Targets[i], nil
|
|
}
|
|
}
|
|
return nil, fmt.Errorf("target %q not found", alias)
|
|
}
|
|
|
|
// HasProduct сообщает, объявлен ли продукт на машине.
|
|
func (t *Target) HasProduct(product string) bool {
|
|
for _, p := range t.Products {
|
|
if p == product {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// SSHAddr возвращает «user@host[:port]» для подключения (порт по умолчанию 22).
|
|
func (t *Target) SSHAddr() string {
|
|
host := t.SSH.Host
|
|
if t.SSH.Port > 0 && t.SSH.Port != 22 {
|
|
host = fmt.Sprintf("%s:%d", host, t.SSH.Port)
|
|
}
|
|
return fmt.Sprintf("%s@%s", t.SSH.User, host)
|
|
}
|
|
|
|
// Loader — источник актуального конфига с live-reload (configreload по контент-хэшу).
|
|
//
|
|
// Приоритет: per-agent <FORGE_TENANT_CONFIG>/lexicomai.json → статический -config.
|
|
type Loader struct {
|
|
reload *configreload.Loader[*Config]
|
|
fallbackPath string
|
|
}
|
|
|
|
// NewLoader создаёт загрузчик. tenantConfigDir — FORGE_TENANT_CONFIG (может быть
|
|
// пустым); configPath — статический -config (может быть пустым).
|
|
func NewLoader(tenantConfigDir, configPath string) *Loader {
|
|
l := &Loader{fallbackPath: configPath}
|
|
if tenantConfigDir != "" {
|
|
l.reload = configreload.New(
|
|
filepath.Join(tenantConfigDir, tenantConfigFileName),
|
|
ParseConfig,
|
|
)
|
|
}
|
|
return l
|
|
}
|
|
|
|
// Get возвращает актуальный конфиг.
|
|
func (l *Loader) Get() (*Config, error) {
|
|
if l.reload != nil {
|
|
cfg, err := l.reload.Get()
|
|
if err == nil {
|
|
return cfg, nil
|
|
}
|
|
// ErrNotFound — штатно (per-agent файла нет) → пробуем фолбэк.
|
|
// Иная ошибка (битый JSON) — тоже к фолбэку, но без фолбэка не молчим.
|
|
if !isNotFound(err) && l.fallbackPath == "" {
|
|
return nil, err
|
|
}
|
|
}
|
|
if l.fallbackPath != "" {
|
|
data, err := os.ReadFile(l.fallbackPath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read config %s: %w", l.fallbackPath, err)
|
|
}
|
|
return ParseConfig(data)
|
|
}
|
|
return nil, fmt.Errorf("no config: %s/%s not found and -config is empty",
|
|
tenantConfigDir(), tenantConfigFileName)
|
|
}
|
|
|
|
func isNotFound(err error) bool {
|
|
return err != nil && strings.Contains(err.Error(), "not found")
|
|
}
|
|
|
|
func tenantConfigDir() string { return os.Getenv(tenantConfigDirEnv) }
|