313 lines
10 KiB
Go
313 lines
10 KiB
Go
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, ", ")
|
||
}
|