Files
forge-tools-lexicomai/internal/lexicomai/remote.go
T
2026-10-01 09:50:59 +07:00

313 lines
10 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, ", ")
}