4adc1398f6
- шаг «База bj»: перенос на сервер PostgreSQL организации (проверка подключения, версии, прав, пустой базы; pg_dump → psql; сверка числа строк по таблицам; переключение и перезапуск); прежняя база — запасная копия; резервные копии: дамп внешней базы по её подключению, восстановление её не трогает - все формы: пустые видимые обязательные поля называются по имени и подсвечиваются; DEPO — и на сервере - «Обновить/Создать канал»: в фоне bj, ход по этапам с таймером (и после перезагрузки страницы), переходы заблокированы до конца, в конце проверка, что шлюз загрузил канал; состояние ИШ во время записи — без очереди к root-обработчику (страница больше не висит на «Проверяю…») Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014FsEKyJrYF69Cj9ZXzFLRi
354 lines
16 KiB
Go
354 lines
16 KiB
Go
package lkgateway
|
||
|
||
// База bj на своём сервере PostgreSQL организации (06.10.2026). Установщик кладёт базу bj на этот же сервер —
|
||
// так и остаётся по умолчанию. Если у организации свой сервер PostgreSQL (с резервным копированием и
|
||
// администратором баз), базу можно держать там: шаг «База bj» проверяет подключение и права, переносит данные
|
||
// (pg_dump этой базы → psql в пустую базу на том сервере), сверяет число строк в каждой таблице, переключает
|
||
// bj и перезапускает его. Прежняя база на этом сервере не удаляется — остаётся запасной копией.
|
||
// Только PostgreSQL: другие СУБД bj для своей базы не поддерживает.
|
||
|
||
import (
|
||
"bufio"
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"io"
|
||
"log"
|
||
"net"
|
||
"net/http"
|
||
"net/url"
|
||
"os"
|
||
"os/exec"
|
||
"sort"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/jackc/pgx/v5"
|
||
)
|
||
|
||
// pgTarget — сервер PostgreSQL организации для базы bj (из формы).
|
||
type pgTarget struct{ Host, Port, DB, User, Password, SSL string }
|
||
|
||
func pgTargetFromForm(r *http.Request) (pgTarget, string) {
|
||
t := pgTarget{
|
||
Host: strings.TrimSpace(r.FormValue("pg_host")), Port: strings.TrimSpace(r.FormValue("pg_port")),
|
||
DB: strings.TrimSpace(r.FormValue("pg_db")), User: strings.TrimSpace(r.FormValue("pg_user")),
|
||
Password: r.FormValue("pg_password"), SSL: r.FormValue("pg_ssl"),
|
||
}
|
||
if t.Port == "" {
|
||
t.Port = "5432"
|
||
}
|
||
switch t.SSL {
|
||
case "disable", "require", "verify-full":
|
||
default:
|
||
t.SSL = "prefer"
|
||
}
|
||
switch {
|
||
case t.Host == "" || strings.ContainsAny(t.Host, "/ @"):
|
||
return t, "укажите адрес сервера PostgreSQL"
|
||
case pgLocalHost(t.Host):
|
||
return t, "это адрес этого же сервера — база bj и так здесь; укажите сервер PostgreSQL организации"
|
||
case t.DB == "" || t.User == "":
|
||
return t, "укажите базу и пользователя"
|
||
}
|
||
if p, err := strconv.Atoi(t.Port); err != nil || p < 1 || p > 65535 {
|
||
return t, "порт — число от 1 до 65535"
|
||
}
|
||
return t, ""
|
||
}
|
||
|
||
// dsn — строка подключения; withPass=false — без пароля (для pg_dump/psql пароль идёт через PGPASSWORD,
|
||
// чтобы не светиться в списке процессов).
|
||
func (t pgTarget) dsn(withPass bool) string {
|
||
u := url.URL{Scheme: "postgres", Host: net.JoinHostPort(t.Host, t.Port), Path: "/" + t.DB}
|
||
if withPass && t.Password != "" {
|
||
u.User = url.UserPassword(t.User, t.Password)
|
||
} else {
|
||
u.User = url.User(t.User)
|
||
}
|
||
u.RawQuery = "sslmode=" + t.SSL
|
||
return u.String()
|
||
}
|
||
|
||
func pgLocalHost(h string) bool {
|
||
h = strings.Trim(strings.ToLower(h), "[]")
|
||
return h == "" || h == "localhost" || h == "127.0.0.1" || h == "::1" || strings.HasPrefix(h, "/")
|
||
}
|
||
|
||
// pgDSNLocal — база по этой строке подключения на этом же сервере.
|
||
func pgDSNLocal(dsn string) bool {
|
||
u, err := url.Parse(strings.TrimSpace(dsn))
|
||
return err != nil || pgLocalHost(u.Hostname())
|
||
}
|
||
|
||
// pgSplitDSN — строка подключения без пароля и пароль отдельно (для внешних программ).
|
||
func pgSplitDSN(dsn string) (string, string) {
|
||
u, err := url.Parse(strings.TrimSpace(dsn))
|
||
if err != nil || u.User == nil {
|
||
return dsn, ""
|
||
}
|
||
pw, _ := u.User.Password()
|
||
u.User = url.User(u.User.Username())
|
||
return u.String(), pw
|
||
}
|
||
|
||
// pgInfo — что известно о базе: версия сервера, права, таблицы.
|
||
type pgInfo struct {
|
||
Version string
|
||
Major int
|
||
CanCreate bool // CREATE в базе: создать схемы bj
|
||
PublicOK bool // CREATE в схеме public (с PostgreSQL 15 по умолчанию есть только у владельца базы)
|
||
Tables []string
|
||
Rows map[string]int64 // для сверки после переноса
|
||
}
|
||
|
||
func pgInspect(ctx context.Context, dsn string, counts bool) (pgInfo, error) {
|
||
info := pgInfo{Rows: map[string]int64{}}
|
||
cctx, cancel := context.WithTimeout(ctx, 15*time.Second)
|
||
defer cancel()
|
||
conn, err := pgx.Connect(cctx, dsn)
|
||
if err != nil {
|
||
return info, err
|
||
}
|
||
defer conn.Close(context.Background())
|
||
var num int
|
||
if err := conn.QueryRow(cctx, "SELECT current_setting('server_version'), current_setting('server_version_num')::int, has_database_privilege(current_user, current_database(), 'CREATE'), COALESCE(has_schema_privilege(current_user, 'public', 'CREATE'), false)").
|
||
Scan(&info.Version, &num, &info.CanCreate, &info.PublicOK); err != nil {
|
||
return info, err
|
||
}
|
||
info.Major = num / 10000
|
||
rows, err := conn.Query(cctx, "SELECT quote_ident(table_schema)||'.'||quote_ident(table_name) FROM information_schema.tables WHERE table_type='BASE TABLE' AND table_schema NOT IN ('pg_catalog','information_schema') AND table_schema NOT LIKE 'pg_toast%' ORDER BY 1")
|
||
if err != nil {
|
||
return info, err
|
||
}
|
||
for rows.Next() {
|
||
var n string
|
||
if rows.Scan(&n) == nil {
|
||
info.Tables = append(info.Tables, n)
|
||
}
|
||
}
|
||
rows.Close()
|
||
if counts {
|
||
for _, t := range info.Tables {
|
||
var c int64
|
||
if err := conn.QueryRow(ctx, "SELECT count(*) FROM "+t).Scan(&c); err != nil {
|
||
return info, fmt.Errorf("подсчёт строк %s: %w", t, err)
|
||
}
|
||
info.Rows[t] = c
|
||
}
|
||
}
|
||
return info, nil
|
||
}
|
||
|
||
// pgPublicUsed — в базе bj есть таблицы в схеме public (тогда на новом сервере нужно право создавать в ней).
|
||
func pgPublicUsed(tables []string) bool {
|
||
for _, t := range tables {
|
||
if strings.HasPrefix(t, "public.") {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// pgCheckTarget — годится ли сервер организации: подключение, версия не старше нашей, права, пустая база.
|
||
func pgCheckTarget(ctx context.Context, src pgInfo, t pgTarget) (pgInfo, string) {
|
||
dst, err := pgInspect(ctx, t.dsn(true), false)
|
||
if err != nil {
|
||
return dst, "не удалось подключиться к " + t.Host + ":" + t.Port + ", база " + t.DB + ": " + pgErrRu(err)
|
||
}
|
||
switch {
|
||
case src.Major > 0 && dst.Major < src.Major:
|
||
return dst, fmt.Sprintf("на сервере организации PostgreSQL %s, а база bj сейчас на %s — нужен PostgreSQL %d или новее", dst.Version, src.Version, src.Major)
|
||
case !dst.CanCreate:
|
||
return dst, "у пользователя " + t.User + " нет права создавать объекты в базе " + t.DB + " (GRANT CREATE ON DATABASE " + t.DB + " TO " + t.User + ") — проще всего сделать его владельцем базы"
|
||
case pgPublicUsed(src.Tables) && !dst.PublicOK:
|
||
return dst, "у пользователя " + t.User + " нет права создавать таблицы в схеме public базы " + t.DB + " (с PostgreSQL 15 оно есть только у владельца базы) — сделайте его владельцем базы: ALTER DATABASE " + t.DB + " OWNER TO " + t.User
|
||
case len(dst.Tables) > 0:
|
||
return dst, fmt.Sprintf("в базе %s уже есть таблицы (%d, например %s) — для bj нужна пустая база", t.DB, len(dst.Tables), dst.Tables[0])
|
||
}
|
||
return dst, ""
|
||
}
|
||
|
||
func pgErrRu(err error) string {
|
||
s := err.Error()
|
||
switch {
|
||
case strings.Contains(s, "password authentication failed"):
|
||
return "неверный пользователь или пароль"
|
||
case strings.Contains(s, "does not exist") && strings.Contains(s, "database"):
|
||
return "такой базы нет — создайте пустую базу"
|
||
case strings.Contains(s, "no pg_hba.conf entry"):
|
||
return "сервер не пускает с адреса bj — администратору баз нужно разрешить подключение в pg_hba.conf"
|
||
case strings.Contains(s, "connection refused"), strings.Contains(s, "i/o timeout"), strings.Contains(s, "no route to host"):
|
||
return "сервер не отвечает на этом адресе и порту (" + s + ")"
|
||
}
|
||
return s
|
||
}
|
||
|
||
// pgCopy — данные базы bj на сервер организации: pg_dump (простой SQL без владельцев и прав) → psql.
|
||
func pgCopy(ctx context.Context, srcDSN string, t pgTarget) error {
|
||
src, srcPass := pgSplitDSN(srcDSN)
|
||
dump := exec.CommandContext(ctx, "pg_dump", "--no-owner", "--no-privileges", "--format=plain", "--dbname="+src)
|
||
dump.Env = append(os.Environ(), "PGPASSWORD="+srcPass)
|
||
load := exec.CommandContext(ctx, "psql", "--quiet", "--no-psqlrc", "-v", "ON_ERROR_STOP=1", "--dbname="+t.dsn(false))
|
||
load.Env = append(os.Environ(), "PGPASSWORD="+t.Password) // без PGOPTIONS: lc_messages — только суперпользователю
|
||
pipe, err := dump.StdoutPipe()
|
||
if err != nil {
|
||
return err
|
||
}
|
||
load.Stdin = pipe
|
||
var dumpErr, loadErr strings.Builder
|
||
dump.Stderr, load.Stderr, load.Stdout = &dumpErr, &loadErr, io.Discard
|
||
if err := load.Start(); err != nil {
|
||
return fmt.Errorf("psql: %w", err)
|
||
}
|
||
if err := dump.Start(); err != nil {
|
||
_ = load.Process.Kill()
|
||
return fmt.Errorf("pg_dump: %w", err)
|
||
}
|
||
de := dump.Wait()
|
||
le := load.Wait()
|
||
if de != nil {
|
||
return fmt.Errorf("pg_dump: %v %s", de, lastLine(dumpErr.String()))
|
||
}
|
||
if le != nil {
|
||
return fmt.Errorf("загрузка на сервер организации: %v %s", le, lastLine(loadErr.String()))
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func lastLine(s string) string {
|
||
sc := bufio.NewScanner(strings.NewReader(s))
|
||
last := ""
|
||
for sc.Scan() {
|
||
if l := strings.TrimSpace(sc.Text()); l != "" {
|
||
last = l
|
||
}
|
||
}
|
||
return last
|
||
}
|
||
|
||
// pgCompare — расхождения числа строк (пусто — всё сошлось).
|
||
func pgCompare(src, dst pgInfo) []string {
|
||
var bad []string
|
||
for _, t := range src.Tables {
|
||
if d, ok := dst.Rows[t]; !ok {
|
||
bad = append(bad, t+": нет на новом сервере")
|
||
} else if d != src.Rows[t] {
|
||
bad = append(bad, fmt.Sprintf("%s: было %d строк, стало %d", t, src.Rows[t], d))
|
||
}
|
||
}
|
||
sort.Strings(bad)
|
||
return bad
|
||
}
|
||
|
||
func (h *setupHandlers) registerPGMove(mux *http.ServeMux) {
|
||
mux.HandleFunc("/admin/setup/postgres/move", h.pgMove)
|
||
}
|
||
|
||
// pgMove — POST /admin/setup/postgres/move: check=1 — только проверить сервер организации; иначе перенести
|
||
// и переключиться. Ход — строками JSON (как подготовка DEPO): этапы и итог.
|
||
func (h *setupHandlers) pgMove(w http.ResponseWriter, r *http.Request) {
|
||
if r.Method != http.MethodPost {
|
||
http.Error(w, "method", http.StatusMethodNotAllowed)
|
||
return
|
||
}
|
||
w.Header().Set("Content-Type", "application/x-ndjson; charset=utf-8")
|
||
w.Header().Set("Cache-Control", "no-store")
|
||
w.Header().Set("X-Accel-Buffering", "no")
|
||
fl, _ := w.(http.Flusher)
|
||
enc := json.NewEncoder(w)
|
||
send := func(v any) {
|
||
_ = enc.Encode(v)
|
||
if fl != nil {
|
||
fl.Flush()
|
||
}
|
||
}
|
||
step := func(key, state, text string) { send(map[string]string{"step": key, "state": state, "text": text}) }
|
||
done := func(ok bool, msg string) {
|
||
send(map[string]any{"done": true, "ok": ok, "msg": msg, "go": "/admin/wizard?step=2&flash=" + url.QueryEscape(msg)})
|
||
}
|
||
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Minute)
|
||
defer cancel()
|
||
|
||
cur := h.rc.Snapshot().Postgres.DSN
|
||
t, bad := pgTargetFromForm(r)
|
||
if bad != "" {
|
||
done(false, bad)
|
||
return
|
||
}
|
||
if !pgDSNLocal(cur) {
|
||
done(false, "база bj уже не на этом сервере — перенос делается с базы на этом сервере")
|
||
return
|
||
}
|
||
step("src", "run", "База bj на этом сервере")
|
||
src, err := pgInspect(ctx, cur, false)
|
||
if err != nil {
|
||
step("src", "err", pgErrRu(err))
|
||
done(false, "база bj на этом сервере недоступна: "+pgErrRu(err))
|
||
return
|
||
}
|
||
step("src", "ok", fmt.Sprintf("PostgreSQL %s, таблиц: %d", src.Version, len(src.Tables)))
|
||
step("dst", "run", "Подключение к серверу организации "+t.Host+":"+t.Port+", база "+t.DB)
|
||
dst, why := pgCheckTarget(ctx, src, t)
|
||
if why != "" {
|
||
step("dst", "err", why)
|
||
done(false, why)
|
||
return
|
||
}
|
||
step("dst", "ok", "PostgreSQL "+dst.Version+", база пустая, права есть")
|
||
if r.FormValue("check") != "" {
|
||
done(true, "Сервер организации подходит: PostgreSQL "+dst.Version+", база "+t.DB+" пустая, права есть. Можно переносить.")
|
||
return
|
||
}
|
||
|
||
step("copy", "run", "Копирование данных bj (pg_dump → psql)")
|
||
start := time.Now()
|
||
if err := pgCopy(ctx, cur, t); err != nil {
|
||
step("copy", "err", err.Error())
|
||
done(false, "данные не перенесены: "+err.Error()+". bj продолжает работать с базой на этом сервере; на сервере организации очистите базу "+t.DB+" и повторите")
|
||
return
|
||
}
|
||
step("copy", "ok", "за "+humanDur(time.Since(start)))
|
||
step("verify", "run", "Сверка: число строк в каждой таблице")
|
||
srcN, err1 := pgInspect(ctx, cur, true)
|
||
dstN, err2 := pgInspect(ctx, t.dsn(true), true)
|
||
if err1 != nil || err2 != nil {
|
||
e := err1
|
||
if e == nil {
|
||
e = err2
|
||
}
|
||
step("verify", "err", e.Error())
|
||
done(false, "сверка не выполнена: "+e.Error()+". bj продолжает работать с базой на этом сервере")
|
||
return
|
||
}
|
||
if diff := pgCompare(srcN, dstN); len(diff) > 0 {
|
||
// пока шло копирование, bj мог дописать строки — это не повод переключаться на неполную базу
|
||
step("verify", "err", strings.Join(diff, "; "))
|
||
done(false, "после копирования данные не совпали ("+strings.Join(diff, "; ")+"). bj продолжает работать с базой на этом сервере; очистите базу "+t.DB+" на сервере организации и повторите в спокойное время")
|
||
return
|
||
}
|
||
var total int64
|
||
for _, n := range srcN.Rows {
|
||
total += n
|
||
}
|
||
step("verify", "ok", fmt.Sprintf("таблиц %d, строк %d — совпадает", len(srcN.Tables), total))
|
||
step("switch", "run", "Переключение bj на сервер организации и перезапуск")
|
||
if err := h.rc.UpdatePostgres(PostgresSettings{DSN: t.dsn(true), Local: cur}); err != nil {
|
||
step("switch", "err", err.Error())
|
||
done(false, "настройки не сохранены: "+err.Error())
|
||
return
|
||
}
|
||
step("switch", "ok", "bj перезапускается — через полминуты он работает с базой на "+t.Host)
|
||
log.Printf("postgres: база bj перенесена на %s:%s/%s (%d таблиц, %d строк); прежняя база на этом сервере сохранена", t.Host, t.Port, t.DB, len(srcN.Tables), total)
|
||
done(true, "База bj перенесена на сервер организации "+t.Host+", база "+t.DB+". Прежняя база на этом сервере сохранена как запасная копия.")
|
||
go func() { // перезапуск после ответа: страница успевает показать итог
|
||
time.Sleep(2 * time.Second)
|
||
rctx, rcancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||
defer rcancel()
|
||
if _, err := restartUnit(rctx, "bj-server"); err != nil {
|
||
log.Printf("postgres: перезапуск после переноса: %v", err)
|
||
}
|
||
}()
|
||
}
|