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