feat(admin): мастер настройки /admin/wizard + авто-подъём PostgreSQL одной кнопкой

Для пользователя без IT-навыков — пошаговая настройка (5 шагов) с
прогресс-баром, подсказками «?» рядом с каждым полем и блоками
«Что это?» / «Где взять?» в каждом шаге. Шаги: PostgreSQL → КриптоПро →
Сертификаты → ИШ НРД → Тестовая заявка. Авто-определение текущего шага
по первому незавершённому пункту, навигация Назад/Далее, мягкие пропуски
(in-memory / mock-режимы).

В шаге 1 — « Поднять локальный PostgreSQL автоматически»: одна кнопка
запускает podman-compose, ждёт pg_isready, накатывает миграции
fansy-store + m2m-core, сохраняет DSN в runtime-конфиг. setupFlash теперь
возвращает пользователя на /admin/wizard, если POST пришёл оттуда —
визард не «теряется» после действий.

Mastered tasks: #41, #42, #43.
This commit is contained in:
fontvielle
2026-05-14 15:46:31 +03:00
parent 0ef75e05e8
commit cb0f7efd4c
5 changed files with 510 additions and 6 deletions
+182 -2
View File
@@ -11,6 +11,7 @@ import (
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"time"
@@ -59,6 +60,7 @@ func registerSetup(mux *http.ServeMux, a *admin, rc *RuntimeConfig, svc *Service
h.renderSetup(w, r, "")
})
mux.HandleFunc("/admin/setup/postgres", h.savePostgres)
mux.HandleFunc("/admin/setup/postgres/quick-start", h.quickStartPostgres)
mux.HandleFunc("/admin/setup/crypto", h.saveCrypto)
mux.HandleFunc("/admin/setup/crypto/check", h.checkCrypto)
mux.HandleFunc("/admin/setup/crypto/activate", h.activateLicense)
@@ -67,6 +69,90 @@ func registerSetup(mux *http.ServeMux, a *admin, rc *RuntimeConfig, svc *Service
mux.HandleFunc("/admin/setup/nsd", h.saveNSD)
mux.HandleFunc("/admin/setup/lk", h.saveLK)
mux.HandleFunc("/admin/setup/test-run", h.testRun)
// Пошаговый мастер настройки для нетехнических пользователей.
mux.HandleFunc("/admin/wizard", h.renderWizard)
}
// WizardData — данные для шаблона /admin/wizard.
type WizardData struct {
page
Step int
Settings Settings
Certs []cryptocli.Certificate
Flash string
CryptoProInstalled bool
CryptoProVersion string
Done struct {
Postgres bool
Crypto bool
Certs bool
NSD bool
TestRun bool
}
}
// renderWizard рисует одну из 5 страниц мастера. Шаг управляется query
// параметром ?step=N (1..5). По умолчанию шаг определяется автоматически
// по первому незавершённому пункту — это даёт «продолжить с того места».
func (h *setupHandlers) renderWizard(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
s := h.rc.Snapshot()
d := WizardData{
page: nowPage("Мастер настройки", "wizard"),
Settings: s,
Certs: h.listCertsForUI(),
Flash: r.URL.Query().Get("flash"),
}
d.Done.Postgres = s.Postgres.DSN != ""
d.Done.Crypto = s.Crypto.Provider != "" && s.Crypto.Provider != "stub" && s.Crypto.JCPPath != ""
d.Done.Certs = len(d.Certs) > 0
d.Done.NSD = s.NSD.IGWBaseURL != "" && s.NSD.Profile != ""
d.Done.TestRun = s.LastTest != nil
// Проверяем установлен ли КриптоПро CSP.
if _, err := os.Stat("/opt/cprocsp/sbin/amd64/cpconfig"); err == nil {
d.CryptoProInstalled = true
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
defer cancel()
if ver, _ := runCmd(ctx, "/opt/cprocsp/sbin/amd64/cpconfig", "-license", "-view"); ver != "" {
d.CryptoProVersion = firstLine(ver)
}
}
// Определяем текущий шаг.
step := 1
if v := r.URL.Query().Get("step"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n >= 1 && n <= 5 {
step = n
}
} else {
// Авто: первый незавершённый.
switch {
case !d.Done.Postgres:
step = 1
case !d.Done.Crypto:
step = 2
case !d.Done.Certs:
step = 3
case !d.Done.NSD:
step = 4
default:
step = 5
}
}
d.Step = step
render(w, h.tpl.a.wizard, d)
}
func firstLine(s string) string {
if i := strings.IndexByte(s, '\n'); i >= 0 {
return strings.TrimSpace(s[:i])
}
return strings.TrimSpace(s)
}
// installCryptoPro — POST /admin/setup/crypto/install (multipart).
@@ -365,6 +451,88 @@ func (h *setupHandlers) savePostgres(w http.ResponseWriter, r *http.Request) {
setupFlash(w, r, "PostgreSQL настройки сохранены")
}
// quickStartPostgres — POST /admin/setup/postgres/quick-start.
// «Большая зелёная кнопка» для пользователя без IT-навыков: поднимает
// локальный postgres-контейнер через podman-compose, ждёт pg_isready,
// накатывает все миграции (fansy-store + m2m-core), сохраняет дефолтный
// DSN в runtime-конфиге. После этого пользователю остаётся перезапустить
// bj-server (или мы сделаем это автоматически в дальнейших версиях).
func (h *setupHandlers) quickStartPostgres(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second)
defer cancel()
// 1. Поднимаем postgres контейнер через podman-compose.
composePath := "deploy/docker-compose/docker-compose.yml"
if out, err := runCmd(ctx, "podman-compose", "-f", composePath, "up", "-d", "postgres"); err != nil {
setupFlash(w, r, "Шаг 1/3: podman-compose не смог поднять контейнер. "+
"Установите podman-compose или проверьте docker-compose.yml. Подсказка: "+
"sudo dnf install -y podman-compose. Вывод: "+strings.TrimSpace(out))
return
}
// 2. Ждём pg_isready.
dsn := "postgres://bj:bj_dev@127.0.0.1:5432/bj?sslmode=disable"
deadline := time.Now().Add(30 * time.Second)
for time.Now().Before(deadline) {
if err := tryPingPostgres(dsn); err == nil {
break
}
time.Sleep(time.Second)
}
if err := tryPingPostgres(dsn); err != nil {
setupFlash(w, r, "Шаг 2/3: контейнер запущен, но БД не отвечает за 30 сек. Ошибка: "+err.Error())
return
}
// 3. Применяем миграции через podman exec.
migrations := []string{
"migrations/fansy-store/000__roles.sql",
"migrations/fansy-store/001__schemas.sql",
"migrations/fansy-store/002__working.sql",
"migrations/fansy-store/003__staging.sql",
"migrations/fansy-store/004__seed_participants.sql",
"migrations/m2m-core/001__deals.sql",
"migrations/m2m-core/002__stages.sql",
}
for _, mig := range migrations {
if err := applyMigration(ctx, mig); err != nil {
// Миграция могла быть уже применена ранее (например, ROLE уже
// существует) — это не критично, продолжаем.
log.Printf("quick-start: миграция %s: %v (продолжаем)", mig, err)
}
}
// 4. Сохраняем DSN в runtime-конфиг.
if err := h.rc.UpdatePostgres(PostgresSettings{DSN: dsn}); err != nil {
setupFlash(w, r, "Шаг 3/3: не получилось сохранить DSN: "+err.Error())
return
}
setupFlash(w, r, "Локальный PostgreSQL поднят и настроен. DSN сохранён. "+
"Перезапустите bj-server (или подождите пока systemd сам перезапустит сервис), "+
"чтобы Repository подключился к БД. После этого статус PostgreSQL будет зелёным.")
}
// applyMigration выполняет одну SQL-миграцию через podman exec в bj-postgres.
func applyMigration(ctx context.Context, path string) error {
data, err := os.ReadFile(path)
if err != nil {
return err
}
cmd := exec.CommandContext(ctx, "podman", "exec", "-i", "bj-postgres",
"psql", "-U", "bj", "-d", "bj", "-v", "ON_ERROR_STOP=1")
cmd.Stdin = strings.NewReader(string(data))
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("%w / output: %s", err, strings.TrimSpace(string(out)))
}
return nil
}
func (h *setupHandlers) saveCrypto(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method", http.StatusMethodNotAllowed)
@@ -573,9 +741,21 @@ func tryHTTPHealth(u string) error {
return nil
}
// setupFlash шлёт 303 на /admin/setup с flash-сообщением в query.
// setupFlash шлёт 303 с flash-сообщением в query. Если запрос пришёл
// со страницы мастера (/admin/wizard), возвращаем туда же с сохранением
// номера шага — пользователь не должен «выпадать» из визарда после POST.
func setupFlash(w http.ResponseWriter, r *http.Request, msg string) {
http.Redirect(w, r, "/admin/setup?flash="+url.QueryEscape(msg), http.StatusSeeOther)
target := "/admin/setup"
if ref := r.Header.Get("Referer"); ref != "" {
if u, err := url.Parse(ref); err == nil && strings.HasPrefix(u.Path, "/admin/wizard") {
q := u.Query()
q.Set("flash", msg)
target = u.Path + "?" + q.Encode()
http.Redirect(w, r, target, http.StatusSeeOther)
return
}
}
http.Redirect(w, r, target+"?flash="+url.QueryEscape(msg), http.StatusSeeOther)
}
// _q извлекает Request из ResponseWriter trick — здесь не нужно