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