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