Files
Bridge-and-Join-s/internal/lkgateway/readiness.go
T

290 lines
12 KiB
Go

package lkgateway
// Готовность к работе — итоговый шаг мастера (05.10.2026): все проверки на одной странице, каждая со
// ссылкой на шаг или раздел, где это исправляется. Обязательные (без них обмен с НРД не работает) —
// красные; желательные (оповещения, копии, время, HTTPS) — жёлтые. Проверки идут параллельно, медленные
// (процедуры DEPO с BJ_PING) не запускаются — берётся последний результат.
import (
"context"
"fmt"
"net/http"
"os/exec"
"sort"
"strings"
"sync"
"time"
)
// ReadyCheck — одна строка итоговой проверки.
type ReadyCheck struct {
Order int `json:"-"`
Title string `json:"title"`
State string `json:"state"` // ok | warn | err | skip
Detail string `json:"detail"`
Link string `json:"link,omitempty"`
Required bool `json:"required"`
}
func readinessChecks(ctx context.Context, rc *RuntimeConfig) []ReadyCheck {
s := rc.Snapshot()
mnp := moduleAllowed(rc, "quik-mnp")
var (
mu sync.Mutex
out []ReadyCheck
wg sync.WaitGroup
)
add := func(c ReadyCheck) {
mu.Lock()
out = append(out, c)
mu.Unlock()
}
run := func(f func() ReadyCheck) {
wg.Add(1)
go func() {
defer wg.Done()
add(f())
}()
}
ok := func(b bool) string {
if b {
return "ok"
}
return "err"
}
run(func() ReadyCheck {
l := licenseStatus(rc)
c := ReadyCheck{Order: 1, Title: "Лицензия", Link: "/admin/wizard?step=1", Required: true, State: ok(l.Valid), Detail: l.Message}
if l.Valid {
c.Detail = pickStr(l.Tenant, "активирована")
if !l.ContractUntil.IsZero() {
c.Detail += ", поддержка до " + l.ContractUntil.Format("02.01.2006")
}
}
return c
})
run(func() ReadyCheck {
st := checkPostgres(ctx, CheckOptions{PostgresDSN: s.Postgres.DSN, Timeout: 3 * time.Second})
c := ReadyCheck{Order: 2, Title: "База bj", Link: "/admin/wizard?step=2", Required: true, State: ok(st.OK && s.Postgres.DSN != ""), Detail: pickStr(st.Message, dsnSummary(s.Postgres.DSN))}
if s.Postgres.DSN == "" {
c.Detail = "база не подключена — заявки не сохраняются"
}
return c
})
if mnp {
run(func() ReadyCheck { return readyDepo(ctx, rc) })
run(func() ReadyCheck {
c := ReadyCheck{Order: 5, Title: "QUIK — модуль неторговых поручений", Link: "/admin/wizard?step=5", Required: true}
cs := quikMNPCheck(ctx, s.Quik)
c.State = mnpWorst(cs)
var bad []string
for _, x := range cs {
if x.State != "ok" {
bad = append(bad, x.Title+": "+x.Detail)
}
}
if len(bad) == 0 {
c.Detail = "подключение, объекты M2M, инструменты, справочник получателей и автоматический цикл — в порядке"
} else {
c.Detail = strings.Join(bad, "; ")
}
return c
})
}
run(func() ReadyCheck {
c := ReadyCheck{Order: 4, Title: "Организация и реквизиты НРД", Link: "/admin/wizard?step=4", Required: true, State: ok(doneOrg(s))}
if doneOrg(s) {
c.Detail = fmt.Sprintf("%s, ИНН %s, код %s; счёт %s, раздел %s", pickStr(s.NSD.OrgName, "—"), orgINN(s), orgCode(s), s.NSD.AccountID, s.NSD.SectionID)
} else {
c.Detail = "не заданы организация или депозитарные реквизиты в НРД"
}
return c
})
run(func() ReadyCheck {
cctx, cancel := context.WithTimeout(ctx, 4*time.Second)
defer cancel()
ver, _, _ := validataVersion(cctx)
out, _ := exec.CommandContext(cctx, "systemctl", "is-active", "bj-crypto.service").Output()
svc := strings.TrimSpace(string(out))
c := ReadyCheck{Order: 6, Title: "СКЗИ «Валидата» и служба криптографии", Link: "/admin/wizard?step=6", Required: true}
switch {
case ver == "":
c.State, c.Detail = "err", "Валидата на сервере не найдена"
case svc != "active":
c.State, c.Detail = "err", "Валидата "+ver+", но служба bj-crypto не запущена ("+pickStr(svc, "нет")+")"
default:
c.State, c.Detail = "ok", "Валидата "+ver+", служба bj-crypto работает"
}
return c
})
run(func() ReadyCheck {
c := ReadyCheck{Order: 7, Title: "Сертификат ключа организации", Link: "/admin/wizard?step=7", Required: true}
own := ownValidCerts(storeCertsForUI(s))
if len(own) == 0 {
c.State, c.Detail = "err", "в справочнике ИШ нет действующего сертификата организации — установите ключ мастером установки ключа"
if len(s.NSDCerts.LastStore) == 0 {
c.Detail = "справочник ИШ ещё не прочитан (появится после установки ключа и настройки шлюза)"
}
return c
}
sort.Slice(own, func(i, j int) bool { return own[i].NotAfter.Before(own[j].NotAfter) })
last := own[len(own)-1]
d := certDaysLeft(last.NotAfter)
c.State, c.Detail = "ok", fmt.Sprintf("«%s» до %s (осталось %d дн.)", last.SubjectCN, last.NotAfter.Format("02.01.2006"), d)
if d <= 30 {
c.State, c.Detail = "warn", c.Detail+" — закажите новый ключ в УЦ Московской биржи"
}
return c
})
run(func() ReadyCheck {
c := ReadyCheck{Order: 8, Title: "Шлюз НРД", Link: "/admin/wizard?step=8", Required: true}
up, msg := igateState(ctx, s.NSD.IGWBaseURL)
switch {
case !up:
c.State, c.Detail = "err", msg
case !doneNSD(s):
c.State, c.Detail = "err", "шлюз отвечает, но контур НРД в bj не выбран"
default:
c.State, c.Detail = "ok", msg+"; контур "+s.NSD.Profile
if down, det := nsdLinkStatus(); down {
c.State, c.Detail = "err", det
}
}
return c
})
run(func() ReadyCheck {
c := ReadyCheck{Order: 9, Title: "Оповещения", Link: "/admin/wizard?step=9"}
smtp := s.Alerts.SMTP
switch {
case smtp.Enabled && smtp.Host != "" && smtp.To != "":
c.State, c.Detail = "ok", "почта на "+smtp.To
if smtp.LastMsg != "" {
c.Detail += " (последняя проверка: " + smtp.LastMsg + ")"
}
if strings.HasPrefix(smtp.LastMsg, "ошибка") {
c.State = "warn"
}
case s.Alerts.Zabbix.Enabled:
c.State, c.Detail = "ok", "Zabbix "+s.Alerts.Zabbix.Server
default:
c.State, c.Detail = "warn", "не настроены: о сбоях (НРД не отвечает, недоставленные переводы, ошибки DEPO, срок сертификата) узнают только открыв bj"
}
return c
})
run(func() ReadyCheck {
c := ReadyCheck{Order: 10, Title: "Резервные копии", Link: "/admin/wizard?step=9"}
b := s.Backup
st := backupStatus()
switch {
case !b.Enabled:
c.State, c.Detail = "warn", "выключены — при сбое сервера настройки, заявки и данные шлюза не восстановить"
case st == nil:
c.State, c.Detail = "warn", fmt.Sprintf("включены (каждые %d ч), копий ещё не было", b.every())
case !st.OK:
c.State, c.Detail = "err", "последняя копия не удалась: "+st.Msg
default:
c.State, c.Detail = "ok", fmt.Sprintf("каждые %d ч, хранение %d дн.; последняя %s", b.every(), b.keep(), backupWhen(st.At))
if t, err := time.Parse(time.RFC3339, st.At); err == nil && time.Since(t) > time.Duration(2*b.every()+1)*time.Hour {
c.State, c.Detail = "warn", c.Detail+" — давно, проверьте таймер bj-backup"
}
}
return c
})
run(func() ReadyCheck {
c := ReadyCheck{Order: 12, Title: "HTTPS", Link: "/admin/setup#server"}
cn, until, err := tlsCertInfo()
switch {
case err != nil:
c.State, c.Detail = "warn", "сертификат HTTPS: "+err.Error()
case certDaysLeft(until) < 14:
c.State, c.Detail = "warn", fmt.Sprintf("сертификат «%s» истекает %s — замените", cn, until.Format("02.01.2006"))
case !s.HTTPS.Redirect:
c.State, c.Detail = "warn", "вход в bj по http:// не перенаправляется на https:// — пароли могут идти по сети открытым текстом"
default:
c.State, c.Detail = "ok", fmt.Sprintf("вход только по HTTPS, сертификат «%s» до %s", cn, until.Format("02.01.2006"))
}
return c
})
run(func() ReadyCheck {
c := ReadyCheck{Order: 11, Title: "Время сервера", Link: "/admin/setup#server"}
cctx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
out, err := exec.CommandContext(cctx, "timedatectl", "show", "-p", "NTPSynchronized", "--value").Output()
switch {
case err != nil:
c.State, c.Detail = "warn", "состояние синхронизации не получено"
case strings.TrimSpace(string(out)) == "yes":
c.State, c.Detail = "ok", "синхронизировано с сервером времени"
default:
c.State, c.Detail = "warn", "время не синхронизируется — часы уплывут, а это важно для подписей и обмена с НРД; укажите сервер времени (NTP)"
}
return c
})
wg.Wait()
sort.Slice(out, func(i, j int) bool { return out[i].Order < out[j].Order })
return out
}
func readyDepo(ctx context.Context, rc *RuntimeConfig) ReadyCheck {
f := rc.Snapshot().Fancy
c := ReadyCheck{Order: 3, Title: "Учёт DEPO", Link: "/admin/wizard?step=3&sub=done", Required: true}
if strings.TrimSpace(f.Server) == "" {
c.State, c.Detail = "err", "база DEPO не подключена"
return c
}
cctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
db, err := fancyOpen(f, f.DepoDB())
if err == nil {
err = db.PingContext(cctx)
}
if err != nil {
c.State, c.Detail = "err", "подключение: "+quikErrorDetail(err)
return c
}
defer db.Close()
var notes []string
c.State = "ok"
if !f.DepoTablesCheckOK {
c.State = "err"
notes = append(notes, "таблицы обмена FTV_M2M: "+pickStr(f.DepoTablesCheck, "не проверены"))
}
if msg := depoObjectsMissing(cctx, db, f); msg != "" {
c.State = "err"
notes = append(notes, msg)
} else if !f.DepoProcCheckOK && f.DepoProcCheck != "" {
c.State = "err"
notes = append(notes, "обработка событий: "+f.DepoProcCheck)
}
if jmsg, jok := depoJobCheck(cctx, rc); !jok {
c.State = "err"
notes = append(notes, "запуск обработки: "+jmsg)
}
if n, _, err := depoAccPrefixInfo(cctx, f); err == nil && n == 0 && !f.IsPostgres() {
c.State = "err"
notes = append(notes, "открытых торговых счетов «"+depoAccPrefix()+"…» в учёте нет — проверьте префикс счёта")
}
if len(notes) == 0 {
c.Detail = f.Server + ", база " + f.DepoDB() + ": таблицы, обработка и счета клиентов на месте"
} else {
c.Detail = strings.Join(notes, "; ")
}
return c
}
func registerReadiness(mux *http.ServeMux, rc *RuntimeConfig) {
mux.HandleFunc("/admin/setup/readiness", func(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
defer cancel()
checks := readinessChecks(ctx, rc)
ready := true
for _, c := range checks {
if c.Required && c.State != "ok" && c.State != "warn" {
ready = false
}
}
jsonOut(w, map[string]any{"checks": checks, "ready": ready, "at": time.Now().Format("02.01.2006 15:04:05")})
})
}