first commit
This commit is contained in:
@@ -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, ", ")
|
||||
}
|
||||
Reference in New Issue
Block a user