first commit

This commit is contained in:
Maksim Totmin
2026-10-01 09:50:59 +07:00
commit 4beb4b55d3
29 changed files with 3034 additions and 0 deletions
+226
View File
@@ -0,0 +1,226 @@
// Package lexicomai — доменный слой MCP-модуля forge-tools/lexicomai.
//
// Модуль управляет жизненным циклом продуктов Lexicom AI, развёрнутых на
// выделенных машинах: инвентарь, статус, конфигурация, лицензии и диагностика.
// Он намеренно НЕ дублирует generic-возможности других forge-tools (ssh —
// удалённый exec, filesystem — локальные файлы, proxmox — гипервизор,
// web — HTTP): здесь только доменная семантика продукта (установка/конфиг/
// лицензия/health/ошибки), а SSH-транспорт — внутренняя «сантехника».
//
// Пакет ничего не знает про MCP и SDK: это чистая доменная логика, которую
// можно тестировать без сети и без SDK.
package lexicomai
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"git.totmin.ru/en2zmax/forge-toolkit"
"git.totmin.ru/en2zmax/forge-toolkit/configreload"
)
// Значения по умолчанию. Принцип fail-closed: отсутствие обязательных полей —
// ошибка, а не «подозрительный» дефолт.
const (
tenantConfigFileName = "lexicomai.json"
tenantConfigDirEnv = "FORGE_TENANT_CONFIG"
// DefaultTimeoutSeconds — таймаут одной SSH-команды/HTTP-пробы. Инструменты
// обязаны завершаться за конечное время, чтобы не блокировать агента.
DefaultTimeoutSeconds = 15
// DefaultMaxOutputBytes — кап тела ответа/вывода, чтобы не отдавать модели
// гигантские результаты; большие значения обрезаются с флагом *_truncated.
DefaultMaxOutputBytes = 4 << 20 // 4 MiB
)
// SSHTarget — параметры подключения к управляемой машине. Хост, пользователь
// и ключ задаёт оператор (в per-agent конфиге), модель их не вводит.
//
// Никакие адреса/подсети/VLAN в код не зашиты: всё приходит из конфига.
type SSHTarget struct {
Host string `json:"host"`
User string `json:"user"`
Key string `json:"key"`
Port int `json:"port"` // SSH-порт; 0 = 22.
}
// Target — одна управляемая машина (alias) и продукты, которые на ней живут.
//
// Alias — стабильный идентификатор, на который ссылается модель
// («платформа объявляет, модель ссылается»). Products — перечень продуктов
// на машине (контролируется оператором). Port — опциональный порт продукта;
// если пуст — порт определяется на лету из systemd-юнита/юнитов (не хардкод).
type Target struct {
Alias string `json:"alias"`
SSH SSHTarget `json:"ssh"`
Products []string `json:"products"`
// Port — порт основного продукта (удобно, когда их несколько и нужен явный
// выбор); по пустому значению порт берётся из службы продукта.
Port int `json:"port"`
// AllowMutate — разрешены ли мутации на этой машине. Второй слой защиты
// поверх approval: fail-closed (false = только чтение).
AllowMutate bool `json:"allow_mutate"`
}
// RepoConfig — доступ к репозиторию дистрибутивов (SFTPGo web-client).
//
// REST /api/v2 и SFTP снаружи не экспонированы, поэтому используется только
// web-client API; креды приходят из ${VAR}/*.local.json, не из кода.
type RepoConfig struct {
Base string `json:"base"`
User string `json:"user"`
Pass string `json:"pass"`
}
// Config — per-agent конфигурация модуля. Файл-шаблон лежит в репозитории,
// реальные значения приходят из <FORGE_TENANT_CONFIG>/lexicomai.json
// (live-reload) или из статического -config как фолбэк.
type Config struct {
// Targets — управляемые машины. Пустой список = fail-closed (работать не с чем).
Targets []Target `json:"targets"`
// Default — alias машины по умолчанию.
Default string `json:"default"`
// ReadOnly — полный запрет мутаций (в доп. к allow_mutate/approval).
ReadOnly *bool `json:"read_only"`
// TimeoutSeconds — таймаут SSH-команд и HTTP-проб.
TimeoutSeconds int `json:"timeout_seconds"`
// MaxOutputBytes — кап результата.
MaxOutputBytes int `json:"max_output_bytes"`
// Repo — доступ к репозиторию.
Repo RepoConfig `json:"repo"`
}
// ParseConfig разбирает сырые байты конфига: раскрывает ${VAR}, парсит JSON,
// применяет дефолты и валидирует. Неразрешённая переменная раскрывается в
// пустую строку и затем отвергается валидацией (fail-closed).
func ParseConfig(data []byte) (*Config, error) {
expanded := toolkit.Expand(data)
var cfg Config
if err := json.Unmarshal(expanded, &cfg); err != nil {
return nil, fmt.Errorf("parse config: %w", err)
}
if len(cfg.Targets) == 0 {
return nil, fmt.Errorf("config: targets is required (empty list — fail-closed)")
}
for i := range cfg.Targets {
t := &cfg.Targets[i]
if strings.TrimSpace(t.Alias) == "" {
return nil, fmt.Errorf("config: targets[%d].alias is required", i)
}
if strings.TrimSpace(t.SSH.Host) == "" {
return nil, fmt.Errorf("config: targets[%d].ssh.host is required", i)
}
if strings.TrimSpace(t.SSH.User) == "" {
return nil, fmt.Errorf("config: targets[%d].ssh.user is required", i)
}
}
if cfg.Default == "" {
cfg.Default = cfg.Targets[0].Alias
}
if cfg.TimeoutSeconds <= 0 {
cfg.TimeoutSeconds = DefaultTimeoutSeconds
}
if cfg.MaxOutputBytes <= 0 {
cfg.MaxOutputBytes = DefaultMaxOutputBytes
}
return &cfg, nil
}
// Timeout возвращает таймаут одной операции как time.Duration.
func (c *Config) Timeout() time.Duration {
return time.Duration(c.TimeoutSeconds) * time.Second
}
// IsReadOnly сообщает, находится ли модуль в режиме только чтения.
func (c *Config) IsReadOnly() bool {
return c.ReadOnly != nil && *c.ReadOnly
}
// Target возвращает машину по alias; пустой alias — «по умолчанию».
func (c *Config) Target(alias string) (*Target, error) {
if alias == "" {
alias = c.Default
}
for i := range c.Targets {
if c.Targets[i].Alias == alias {
return &c.Targets[i], nil
}
}
return nil, fmt.Errorf("target %q not found", alias)
}
// HasProduct сообщает, объявлен ли продукт на машине.
func (t *Target) HasProduct(product string) bool {
for _, p := range t.Products {
if p == product {
return true
}
}
return false
}
// SSHAddr возвращает «user@host[:port]» для подключения (порт по умолчанию 22).
func (t *Target) SSHAddr() string {
host := t.SSH.Host
if t.SSH.Port > 0 && t.SSH.Port != 22 {
host = fmt.Sprintf("%s:%d", host, t.SSH.Port)
}
return fmt.Sprintf("%s@%s", t.SSH.User, host)
}
// Loader — источник актуального конфига с live-reload (configreload по контент-хэшу).
//
// Приоритет: per-agent <FORGE_TENANT_CONFIG>/lexicomai.json → статический -config.
type Loader struct {
reload *configreload.Loader[*Config]
fallbackPath string
}
// NewLoader создаёт загрузчик. tenantConfigDir — FORGE_TENANT_CONFIG (может быть
// пустым); configPath — статический -config (может быть пустым).
func NewLoader(tenantConfigDir, configPath string) *Loader {
l := &Loader{fallbackPath: configPath}
if tenantConfigDir != "" {
l.reload = configreload.New(
filepath.Join(tenantConfigDir, tenantConfigFileName),
ParseConfig,
)
}
return l
}
// Get возвращает актуальный конфиг.
func (l *Loader) Get() (*Config, error) {
if l.reload != nil {
cfg, err := l.reload.Get()
if err == nil {
return cfg, nil
}
// ErrNotFound — штатно (per-agent файла нет) → пробуем фолбэк.
// Иная ошибка (битый JSON) — тоже к фолбэку, но без фолбэка не молчим.
if !isNotFound(err) && l.fallbackPath == "" {
return nil, err
}
}
if l.fallbackPath != "" {
data, err := os.ReadFile(l.fallbackPath)
if err != nil {
return nil, fmt.Errorf("read config %s: %w", l.fallbackPath, err)
}
return ParseConfig(data)
}
return nil, fmt.Errorf("no config: %s/%s not found and -config is empty",
tenantConfigDir(), tenantConfigFileName)
}
func isNotFound(err error) bool {
return err != nil && strings.Contains(err.Error(), "not found")
}
func tenantConfigDir() string { return os.Getenv(tenantConfigDirEnv) }
+52
View File
@@ -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)
}
}
+160
View File
@@ -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 ""
}
+64
View File
@@ -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)
}
}
+17
View File
@@ -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)
}
+246
View File
@@ -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 ""
}
+71
View File
@@ -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:]
}
+42
View File
@@ -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")
}
}
+152
View File
@@ -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
}
+137
View File
@@ -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
}
+81
View File
@@ -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")
}
}
+56
View File
@@ -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
}
+34
View File
@@ -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])
}
}
}
+312
View File
@@ -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, ", ")
}
+167
View File
@@ -0,0 +1,167 @@
package lexicomai
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/cookiejar"
"net/url"
"sort"
"strings"
)
// repoFile — элемент листинга каталога SFTPGo web-client (/dirs).
type repoFile struct {
Name string `json:"name"`
Type int `json:"type"` // 1 = папка, 2 = файл
Size int64 `json:"size"`
DirPath string `json:"dir_path"`
}
// repoClient — аутентифицированный клиент web-API репозитория.
//
// Доступ — только web-client (REST /api/v2 и SFTP снаружи закрыты):
//
// POST {base}/sftpgo/web/client/login (form) → Set-Cookie: jwt
//
// Креды — из конфига (${VAR}), никогда из кода. Отсутствие кред — fail-closed.
func (m *Manager) repoClient(ctx context.Context, cfg *Config) (*http.Client, error) {
base := strings.TrimRight(cfg.Repo.Base, "/")
if base == "" || cfg.Repo.User == "" || cfg.Repo.Pass == "" {
return nil, fmt.Errorf("repo not configured (repo.base/user/pass)")
}
jar, _ := cookiejar.New(nil)
client := &http.Client{Jar: jar}
loginBody := "username=" + url.QueryEscape(cfg.Repo.User) + "&password=" + url.QueryEscape(cfg.Repo.Pass)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, base+"/sftpgo/web/client/login", strings.NewReader(loginBody))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Accept", "application/json")
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("repo login: %w", err)
}
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
return client, nil
}
// repoList возвращает имена файлов в /repos/<product>/<branch> (только .sh).
func (m *Manager) repoList(ctx context.Context, client *http.Client, base, product, branch string) ([]string, error) {
p := fmt.Sprintf("/repos/%s/%s", product, branch)
listURL := base + "/sftpgo/web/client/dirs?path=" + url.QueryEscape(p)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, listURL, nil)
if err != nil {
return nil, err
}
req.Header.Set("Accept", "application/json")
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("list %s: http %d", p, resp.StatusCode)
}
var items []repoFile
if err := json.NewDecoder(resp.Body).Decode(&items); err != nil {
return nil, fmt.Errorf("decode %s: %w", p, err)
}
var names []string
for _, it := range items {
if it.Type == 2 && strings.HasSuffix(it.Name, ".sh") {
names = append(names, it.Name)
}
}
return names, nil
}
// RepoCatalog возвращает версии продукта в ветках release/beta из репозитория.
func (m *Manager) RepoCatalog(ctx context.Context, product string) (string, error) {
cfg, err := m.resolve()
if err != nil {
return "", err
}
client, err := m.repoClient(ctx, cfg)
if err != nil {
return "", err
}
base := strings.TrimRight(cfg.Repo.Base, "/")
var b strings.Builder
for _, branch := range []string{"release", "beta"} {
files, err := m.repoList(ctx, client, base, product, branch)
if err != nil {
fmt.Fprintf(&b, "%s: %v\n", branch, err)
continue
}
sort.Strings(files)
if len(files) == 0 {
fmt.Fprintf(&b, "%s: (empty)\n", branch)
continue
}
fmt.Fprintf(&b, "%s:\n", branch)
for _, f := range files {
b.WriteString(" " + f + "\n")
}
}
return strings.TrimRight(b.String(), "\n"), nil
}
// RepoResolve находит имя файла дистрибутива для продукта/версии/ветки,
// обращаясь к репозиторию. Пустая версия → последняя в ветке.
func (m *Manager) RepoResolve(ctx context.Context, product, version, branch string) (string, error) {
branch, err := normalizeBranch(branch)
if err != nil {
return "", err
}
cfg, err := m.resolve()
if err != nil {
return "", err
}
client, err := m.repoClient(ctx, cfg)
if err != nil {
return "", err
}
base := strings.TrimRight(cfg.Repo.Base, "/")
names, err := m.repoList(ctx, client, base, product, branch)
if err != nil {
return "", err
}
return ResolveDistro(names, product, version, branch)
}
// RepoStream открывает поток скачивания дистрибутива (web-client download=1).
// Вызывающий обязан закрыть поток. Стриминг — без буферизации целиком
// (файлы могут быть до ~1.2 ГБ).
func (m *Manager) RepoStream(ctx context.Context, product, branch, name string) (io.ReadCloser, error) {
cfg, err := m.resolve()
if err != nil {
return nil, err
}
client, err := m.repoClient(ctx, cfg)
if err != nil {
return nil, err
}
base := strings.TrimRight(cfg.Repo.Base, "/")
p := fmt.Sprintf("/repos/%s/%s/%s", product, branch, name)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, base+"/sftpgo/web/client/files?path="+url.QueryEscape(p)+"&download=1", nil)
if err != nil {
return nil, err
}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusOK {
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
return nil, fmt.Errorf("download %s: http %d", p, resp.StatusCode)
}
return resp.Body, nil
}
+60
View File
@@ -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
}
+121
View File
@@ -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"
}
+144
View File
@@ -0,0 +1,144 @@
package lexicomai
import (
"fmt"
"regexp"
"sort"
"strconv"
"strings"
)
// distroNameRe — имя дистрибутива в репозитории: <product>-<version>-<branch>.sh
// (например ups-7.012-release.sh, ses-7.045-beta.sh). Версия может содержать
// точки, ветка — release|beta.
var distroNameRe = regexp.MustCompile(`^(?P<product>[A-Za-z0-9_-]+)-(?P<version>[0-9][0-9.]*)-(?P<branch>release|beta)\.sh$`)
// Version — семантическая версия (разбивка по точкам). Сравнение числовое,
// поэтому 7.12 > 7.9 (в отличие от лексикографического).
type Version []int
// ParseVersion разбирает строку версии "7.012" в числовые сегменты.
// Нечисловой сегмент обрезается (считаем по корректному префиксу).
func ParseVersion(s string) Version {
parts := strings.Split(s, ".")
v := make(Version, 0, len(parts))
for _, p := range parts {
n, err := strconv.Atoi(strings.TrimSpace(p))
if err != nil {
break
}
v = append(v, n)
}
return v
}
// String возвращает канонический вид версии (сегменты через точку).
func (v Version) String() string {
s := make([]string, len(v))
for i, n := range v {
s[i] = strconv.Itoa(n)
}
return strings.Join(s, ".")
}
// Compare сравнивает две версии семантически.
func CompareVersions(a, b string) int {
return ParseVersion(a).Compare(ParseVersion(b))
}
// Compare сравнивает две версии посегментно (короткая версия считается меньшей
// при равенстве общих сегментов, что соответствует общепринятой семантике).
func (v Version) Compare(o Version) int {
n := len(v)
if len(o) < n {
n = len(o)
}
for i := 0; i < n; i++ {
if v[i] < o[i] {
return -1
}
if v[i] > o[i] {
return 1
}
}
switch {
case len(v) < len(o):
return -1
case len(v) > len(o):
return 1
default:
return 0
}
}
// distroParts — разобранное имя дистрибутива.
type distroParts struct {
Product string
Version string
Branch string
}
// parseDistroName разбирает имя файла дистрибутива.
func parseDistroName(name string) (distroParts, error) {
m := distroNameRe.FindStringSubmatch(name)
if m == nil {
return distroParts{}, fmt.Errorf("not a distro name: %q", name)
}
return distroParts{
Product: m[1],
Version: m[2],
Branch: m[3],
}, nil
}
// ResolveDistro выбирает имя файла дистрибутива из списка кандидатов.
//
// product — продукт; version — требуемая версия (пустая = последняя в ветке);
// branch — release|beta (по умолчанию release). Возвращает имя файла.
// Если по условиям кандидатов нет — доменная ошибка (модель может исправить).
func ResolveDistro(names []string, product, version, branch string) (string, error) {
if branch == "" {
branch = "release"
}
// Собираем кандидаты: продукт + ветка, отсортированные по убыванию версии.
var candidates []distroParts
for _, n := range names {
p, err := parseDistroName(n)
if err != nil {
continue
}
if p.Product == product && p.Branch == branch {
candidates = append(candidates, p)
}
}
if len(candidates) == 0 {
return "", fmt.Errorf("no %s distro in %s for %q", product, branch, product)
}
sort.SliceStable(candidates, func(i, j int) bool {
return CompareVersions(candidates[i].Version, candidates[j].Version) > 0
})
if version != "" {
for _, c := range candidates {
if c.Version == version {
return product + "-" + c.Version + "-" + c.Branch + ".sh", nil
}
}
return "", fmt.Errorf("version %q of %s not found in %s", version, product, branch)
}
top := candidates[0]
return product + "-" + top.Version + "-" + top.Branch + ".sh", nil
}
// normalizeBranch приводит значение ветки к каноническому виду (release/beta).
func normalizeBranch(branch string) (string, error) {
branch = strings.ToLower(strings.TrimSpace(branch))
switch branch {
case "", "release":
return "release", nil
case "beta":
return "beta", nil
default:
return "", fmt.Errorf("unknown branch %q (expected release or beta)", branch)
}
}
+77
View File
@@ -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")
}
}