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
+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, ", ")
}