Files
2026-10-01 09:50:59 +07:00

161 lines
5.7 KiB
Go

package lexicomai
import (
"context"
"fmt"
"regexp"
"strconv"
"strings"
)
// ServiceInfo — описание службы продукта, извлечённое из systemd-юнита и
// HTTP-проб. Всё выводится из живой системы (не захардкожено): движок, порт,
// venv и лицензия определяются на лету.
type ServiceInfo struct {
Unit string
Active bool
SubState string
Engine string // uvicorn / gunicorn / python / bwrap / other
Binary string // путь к исполняемому (например /opt/pve/bin/uvicorn)
Venv string // корень venv (например /opt/pve/ups)
Port int
RawExec string
}
var (
reActive = regexp.MustCompile(`ActiveState=([A-Za-z]+)`)
reSub = regexp.MustCompile(`SubState=([A-Za-z]+)`)
reBind = regexp.MustCompile(`(?:--bind\s+\S*:(\d+)|0\.0\.0\.0:(\d+)|--port\s+(\d+)|127\.0\.0\.1:(\d+))`)
reBin = regexp.MustCompile(`(?:/opt/pve[^ ;]*/bin/([a-zA-Z0-9_.-]+)|/usr/bin/bwrap|/opt/[a-zA-Z0-9_.-]+/bin/python[a-z0-9]*)`)
)
// parsePortAndEngine извлекает порт, движок и бинарник из строки ExecStart.
func parsePortAndEngine(execRaw string) (port int, engine, binary string) {
if m := reBind.FindStringSubmatch(execRaw); m != nil {
for _, g := range m[1:] {
if g != "" {
if p, err := strconv.Atoi(g); err == nil {
port = p
break
}
}
}
}
bin := ""
if m := reBin.FindString(execRaw); m != "" {
bin = m
}
switch {
case strings.Contains(bin, "/bin/uvicorn"):
engine = "uvicorn"
case strings.Contains(bin, "/bin/gunicorn"):
engine = "gunicorn"
case strings.Contains(bin, "/bin/python"):
engine = "python"
case strings.Contains(strings.ToLower(execRaw), "bwrap"):
engine = "bwrap"
default:
engine = "other"
}
return port, engine, bin
}
// venvFromBinary берёт корень venv из пути бинарника:
//
// /opt/pve/ups/bin/uvicorn -> /opt/pve/ups
// /opt/pve/bin/uvicorn -> /opt/pve
func venvFromBinary(binary string) string {
if binary == "" {
return ""
}
idx := strings.Index(binary, "/bin/")
if idx <= 0 {
return ""
}
return binary[:idx]
}
// ServiceQuery возвращает состояние службы продукта на машине.
//
// Источник истины — действующий systemd-юнит (ExecStart/ActiveState), поэтому
// двигатель/порт/venv не дублируются в коде и не зависят от версии продукта.
func (m *Manager) ServiceQuery(ctx context.Context, target *Target, product string) (*ServiceInfo, error) {
unit := ServiceUnit(product)
out, err := m.runSudo(ctx, target, "systemctl show -p ExecStart,ActiveState,SubState "+shellQuote(unit))
if err != nil {
return nil, fmt.Errorf("query unit %s: %w", unit, err)
}
info := &ServiceInfo{Unit: unit, Engine: "other"}
info.Active = reActive.MatchString(out) && reActive.FindStringSubmatch(out)[1] == "active"
if m := reSub.FindStringSubmatch(out); m != nil {
info.SubState = m[1]
}
// ExecStart-строка — внутри `ExecStart={ ... }`.
execLine := firstLineContaining(out, "ExecStart=")
raw := strings.TrimSpace(execLine)
info.RawExec = raw
port, engine, binary := parsePortAndEngine(raw)
info.Port = port
info.Engine = engine
info.Binary = binary
info.Venv = venvFromBinary(binary)
return info, nil
}
// firstLineContaining возвращает первую строку, содержащую подстроку needle.
func firstLineContaining(s, needle string) string {
for _, line := range strings.Split(s, "\n") {
if strings.Contains(line, needle) {
return line
}
}
return ""
}
// OpenAPI — метаданные живого продукта, когда они доступны (FastAPI/uvicorn
// отдают /openapi.json; 200 = сервис жив).
type OpenAPI struct {
Title string
Version string
Alive bool
}
// ProbeOpenAPI читает /openapi.json продукта (если движок его отдаёт) для
// подтверждения живости и извлечения версии. Ошибка (недоступен порт/нет
// openapi) — не блокирует: приводятся в health как «не проверено».
func (m *Manager) ProbeOpenAPI(ctx context.Context, target *Target, port int) (OpenAPI, error) {
status, body, err := m.probeHTTP(ctx, target, port, "/openapi.json")
if err != nil {
return OpenAPI{}, err
}
info := OpenAPI{Alive: status == 200}
if status != 200 {
return info, fmt.Errorf("openapi.json http %d", status)
}
info.Title = jsonString(body, "title")
info.Version = jsonString(body, "version")
return info, nil
}
// ProbeLicenseCheck вызывает /license/check продукта. HTTP 200 обычно означает,
// что лицензия валидна; 4xx/5xx — отклонена/недоступна. Итог нормализуется в
// булев признак (body не логируется целиком).
func (m *Manager) ProbeLicenseCheck(ctx context.Context, target *Target, port int) (ok bool, statusCode int, err error) {
status, _, err := m.probeHTTP(ctx, target, port, "/license/check")
if err != nil {
return false, 0, err
}
return status >= 200 && status < 300, status, nil
}
func jsonString(body, key string) string {
// Лёгкий поиск "key":"value" без привязки к структуре — идеально для
// info.title/info.version в openapi.json. Полный JSON-парсинг не нужен.
re := regexp.MustCompile(`"` + regexp.QuoteMeta(key) + `"\s*:\s*"([^"]*)"`)
if m := re.FindStringSubmatch(body); m != nil {
return m[1]
}
return ""
}