161 lines
5.7 KiB
Go
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 ""
|
|
}
|