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