Files
2026-10-01 09:50:59 +07:00

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) }