Компоненты
-
Каждый компонент обновляется отдельно: своя версия в релизе, перезапускается только его служба. Файлы ставит root-установщик после повторной проверки подписи и контрольных сумм. Модули, которых нет на этом сервере, обновлением не ставятся.
+
Каждый компонент обновляется отдельно: своя версия в релизе, перезапускается только его служба. Файлы ставит root-установщик после повторной проверки подписи и контрольных сумм. Модули, которых нет на этом сервере, обновлением не ставятся — кроме выделенных из ядра (НПР / УДС, экспорт QUIK): если модуль входит в лицензию, он ставится вместе с обновлением, иначе его раздел перестал бы работать.
| Компонент | Установлена | Доступна | Служба | |
{{range .Components}}
| {{.Title}} {{.Name}}
|
{{if .Present}}{{if .Installed}}{{.Installed}}{{else}}неизвестна{{end}}{{else}}не установлен{{end}} |
- {{if .Available}}{{.Available}}{{if .Update}} новее{{end}}{{else}}нет данных{{end}} |
+ {{if .Available}}{{.Available}}{{if .New}} к установке{{else if .Update}} новее{{end}}{{else}}нет данных{{end}} |
{{.Unit}} |
- {{if .Update}}{{end}} |
+ {{if .Update}}{{end}} |
{{end}}
diff --git a/internal/release/components.go b/internal/release/components.go
index 2127412..f14e266 100644
--- a/internal/release/components.go
+++ b/internal/release/components.go
@@ -21,6 +21,9 @@ type Component struct {
Unit string // служба systemd, перезапускаемая после установки ("" — без перезапуска)
Exec bool // исполняемый файл
Core bool // ядро: ставится всегда; модули — только если уже установлены на этом сервере
+ // FromCore — модуль выделен из bj-server: обновление ставит его и там, где его ещё нет, если он
+ // входит в лицензию (иначе после обновления ядра раздел модуля перестал бы работать).
+ FromCore bool
// Feature — признак лицензии, без которого сервер обновлений не отдаёт файл компонента.
Feature string
}
@@ -31,8 +34,8 @@ var Components = []Component{
{Name: "bj-component-install", Title: "Установщик обновлений", Path: "/usr/local/sbin/bj-component-install", Exec: true, Core: true, Feature: "updates"},
{Name: "crypto-service.jar", Title: "Криптосервис", Path: "/opt/bj/crypto-service.jar", Unit: "bj-crypto", Feature: "updates"},
{Name: "bj-lk", Title: "Личный кабинет клиента", Path: "/opt/bj/bj-lk", Unit: "bj-lk", Exec: true, Feature: "web-cabinet"},
- {Name: "bj-npr", Title: "Риск-монитор НПР / УДС", Path: "/opt/bj/bj-npr", Unit: "bj-npr", Exec: true, Feature: "npr"},
- {Name: "bj-quikexport", Title: "Экспорт QUIK", Path: "/opt/bj/bj-quikexport", Unit: "bj-quikexport", Exec: true, Feature: "quik-export"},
+ {Name: "bj-npr", Title: "Риск-монитор НПР / УДС", Path: "/opt/bj/bj-npr", Unit: "bj-npr", Exec: true, FromCore: true, Feature: "npr"},
+ {Name: "bj-quikexport", Title: "Экспорт QUIK", Path: "/opt/bj/bj-quikexport", Unit: "bj-quikexport", Exec: true, FromCore: true, Feature: "quik-export"},
}
// ComponentByName — компонент по имени артефакта.
@@ -96,6 +99,7 @@ type ComponentState struct {
Available string // версия в манифесте ("" — компонента нет в релизе)
SameFile bool // установленный файл совпадает с файлом релиза
Update bool // можно обновить
+ New bool // модуль выделен из ядра и ставится впервые (FromCore)
}
// Plan — что можно обновить. m == nil — только состояние установленного (без сети).
@@ -133,6 +137,17 @@ func Plan(root string, m *Manifest, installed map[string]Installed, selfVersion
return out
}
+// MarkSplitModules — отметить к установке выделенные из ядра модули (FromCore), которых ещё нет на
+// этом сервере, если они есть в релизе и разрешены лицензией (allowed — по признаку компонента).
+func MarkSplitModules(states []ComponentState, allowed func(feature string) bool) {
+ for i := range states {
+ s := &states[i]
+ if s.FromCore && !s.Present && s.Available != "" && allowed(s.Feature) {
+ s.Update, s.New = true, true
+ }
+ }
+}
+
// cachedHash — sha256 файла с кэшем по размеру и времени изменения: страница настроек
// не пересчитывает десятки мегабайт на каждый показ.
var hashCache = struct {
diff --git a/internal/release/components_test.go b/internal/release/components_test.go
index 8b88ad4..ec6fce0 100644
--- a/internal/release/components_test.go
+++ b/internal/release/components_test.go
@@ -64,3 +64,24 @@ func TestPlanIndependentVersions(t *testing.T) {
t.Fatalf("откат на 0.9.0 не должен предлагаться: %+v", s)
}
}
+
+func TestSplitModulesInstalledWithCore(t *testing.T) {
+ root := t.TempDir()
+ writeComp(t, root, "bj-server", "core")
+ m := &Manifest{Version: "2026.09.2", Artifacts: []Artifact{
+ {Name: "bj-npr", Version: "0.1.0", SHA256: "n"},
+ {Name: "bj-quikexport", Version: "0.1.0", SHA256: "q"},
+ {Name: "bj-lk", Version: "0.2.0", SHA256: "l"},
+ }}
+ st := Plan(root, m, nil, "2.0.0")
+ MarkSplitModules(st, func(f string) bool { return f == "npr" || f == "web-cabinet" })
+ if s := state(st, "bj-npr"); !s.Update || !s.New {
+ t.Fatalf("выделенный из ядра модуль по лицензии должен ставиться: %+v", s)
+ }
+ if s := state(st, "bj-quikexport"); s.Update || s.New {
+ t.Fatalf("модуль без лицензии не ставится: %+v", s)
+ }
+ if s := state(st, "bj-lk"); s.Update || s.New {
+ t.Fatalf("самостоятельный модуль обновлением не ставится: %+v", s)
+ }
+}
diff --git a/internal/release/install.go b/internal/release/install.go
index 4e0a8a8..207d25f 100644
--- a/internal/release/install.go
+++ b/internal/release/install.go
@@ -11,6 +11,7 @@ import (
"errors"
"fmt"
"io"
+ "io/fs"
"os"
"os/exec"
"path/filepath"
@@ -29,6 +30,7 @@ type InstallOptions struct {
PubKey ed25519.PublicKey // открытый ключ подписи релизов
Restart bool // перезапустить службы установленных компонентов (ядро последним)
AllowNew bool // ставить модули, которых ещё нет на сервере (первичная установка)
+ Units fs.FS // unit-файлы служб (systemd/<служба>.service) для впервые устанавливаемых модулей
}
// InstallResult — итог установки (JSON для bj-server).
@@ -68,6 +70,7 @@ func InstallStaged(staging string, opt InstallOptions) (res InstallResult) {
res.Release = m.Version
var req struct {
Names []string `json:"names"`
+ New []string `json:"new"` // выделенные из ядра модули, которые ставятся впервые
}
if err := readStagedJSON(staging, "request.json", &req); err != nil {
return fail("запрос: %v", err)
@@ -105,7 +108,7 @@ func InstallStaged(staging string, opt InstallOptions) (res InstallResult) {
if art.Version == "" {
art.Version = m.Version
}
- if _, err := os.Stat(c.PathAt(opt.Root)); err != nil && !c.Core && !opt.AllowNew {
+ if _, err := os.Stat(c.PathAt(opt.Root)); err != nil && !c.Core && !opt.AllowNew && !(c.FromCore && contains(req.New, name)) {
return fail("%s не установлен на этом сервере: модуль ставится установщиком, а не обновлением", name)
}
if prev, ok := installed[name]; ok && prev.Version != "" && IsNewer(prev.Version, art.Version) {
@@ -124,7 +127,7 @@ func InstallStaged(staging string, opt InstallOptions) (res InstallResult) {
sort.SliceStable(jobs, func(i, j int) bool { return componentOrder(jobs[i].c.Name) < componentOrder(jobs[j].c.Name) })
ts := time.Now().Format("20060102-150405")
- var units []string
+ var units, newUnits []string
for _, j := range jobs {
if err := installFile(j.file, j.c.PathAt(opt.Root), j.c.Exec, ts); err != nil {
return fail("установка %s: %v", j.c.Name, err)
@@ -133,6 +136,21 @@ func InstallStaged(staging string, opt InstallOptions) (res InstallResult) {
res.Installed = append(res.Installed, map[string]string{"name": j.c.Name, "version": j.a.Version})
if j.c.Unit != "" {
units = append(units, j.c.Unit)
+ added, err := ensureUnit(opt.Root, opt.Units, j.c.Unit)
+ if err != nil {
+ return fail("служба %s: %v", j.c.Unit, err)
+ }
+ if added {
+ newUnits = append(newUnits, j.c.Unit)
+ }
+ }
+ }
+ if len(newUnits) > 0 && opt.Root == "" {
+ if out, err := exec.Command("systemctl", "daemon-reload").CombinedOutput(); err != nil {
+ return fail("systemctl daemon-reload: %v: %s", err, strings.TrimSpace(string(out)))
+ }
+ if out, err := exec.Command("systemctl", append([]string{"enable"}, newUnits...)...).CombinedOutput(); err != nil {
+ return fail("включение служб %s: %v: %s", strings.Join(newUnits, ", "), err, strings.TrimSpace(string(out)))
}
}
if err := SaveInstalled(installedPath, installed); err != nil {
@@ -153,6 +171,31 @@ func InstallStaged(staging string, opt InstallOptions) (res InstallResult) {
return res
}
+// ensureUnit — поставить unit-файл службы из комплекта, если его ещё нет (true — поставлен).
+func ensureUnit(root string, units fs.FS, unit string) (bool, error) {
+ dst := filepath.Join(root, "/etc/systemd/system", unit+".service")
+ if _, err := os.Stat(dst); err == nil || units == nil {
+ return false, nil
+ }
+ b, err := fs.ReadFile(units, "systemd/"+unit+".service")
+ if err != nil {
+ return false, nil // в комплекте нет — служба ставится установщиком
+ }
+ if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
+ return false, err
+ }
+ return true, os.WriteFile(dst, b, 0o644)
+}
+
+func contains(list []string, v string) bool {
+ for _, x := range list {
+ if x == v {
+ return true
+ }
+ }
+ return false
+}
+
func componentOrder(name string) int {
for i, c := range Components {
if c.Name == name {
diff --git a/internal/release/install_test.go b/internal/release/install_test.go
new file mode 100644
index 0000000..7a91901
--- /dev/null
+++ b/internal/release/install_test.go
@@ -0,0 +1,75 @@
+package release
+
+import (
+ "crypto/ed25519"
+ "crypto/rand"
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "testing/fstest"
+ "time"
+)
+
+func stageNPR(t *testing.T, priv ed25519.PrivateKey, req string) string {
+ t.Helper()
+ staging := t.TempDir()
+ bin := filepath.Join(staging, "bj-npr")
+ if err := os.WriteFile(bin, []byte("npr 0.1.0"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ sha, size, err := HashFile(bin)
+ if err != nil {
+ t.Fatal(err)
+ }
+ m := &Manifest{Version: "2026.09.2", Channel: "beta", ReleasedAt: time.Now().UTC().Truncate(time.Second),
+ Artifacts: []Artifact{{Name: "bj-npr", File: "bj-npr", Version: "0.1.0", SHA256: sha, Size: size, Exec: true}}}
+ sm, err := Sign(m, priv, "main")
+ if err != nil {
+ t.Fatal(err)
+ }
+ b, _ := json.Marshal(sm)
+ if err := os.WriteFile(filepath.Join(staging, "manifest.json"), b, 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(staging, "request.json"), []byte(req), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ return staging
+}
+
+// Выделенный из ядра модуль ставится впервые только по явному запросу (new) — вместе с unit-файлом.
+func TestInstallSplitModuleWithUnit(t *testing.T) {
+ pub, priv, err := ed25519.GenerateKey(rand.Reader)
+ if err != nil {
+ t.Fatal(err)
+ }
+ units := fstest.MapFS{"systemd/bj-npr.service": {Data: []byte("[Service]\nExecStart=/opt/bj/bj-npr\n")}}
+
+ root := t.TempDir()
+ res := InstallStaged(stageNPR(t, priv, `{"names":["bj-npr"]}`), InstallOptions{Root: root, PubKey: pub, Units: units})
+ if res.OK || !strings.Contains(res.Error, "не установлен") {
+ t.Fatalf("без new модуль не должен ставиться: %+v", res)
+ }
+
+ res = InstallStaged(stageNPR(t, priv, `{"names":["bj-npr"],"new":["bj-npr"]}`), InstallOptions{Root: root, PubKey: pub, Units: units})
+ if !res.OK {
+ t.Fatalf("установка: %+v", res)
+ }
+ if fi, err := os.Stat(filepath.Join(root, "/opt/bj/bj-npr")); err != nil || fi.Mode().Perm() != 0o755 {
+ t.Fatalf("файл модуля: %v %v", fi, err)
+ }
+ if b, err := os.ReadFile(filepath.Join(root, "/etc/systemd/system/bj-npr.service")); err != nil || !strings.Contains(string(b), "ExecStart=/opt/bj/bj-npr") {
+ t.Fatalf("unit-файл: %q %v", b, err)
+ }
+ if inst := LoadInstalled(filepath.Join(root, InstalledFile)); inst["bj-npr"].Version != "0.1.0" {
+ t.Fatalf("components.json: %+v", inst)
+ }
+
+ // Самостоятельный модуль (не FromCore) по new не ставится.
+ c, _ := ComponentByName("bj-lk")
+ if c.FromCore {
+ t.Fatal("bj-lk не выделялся из ядра")
+ }
+}
diff --git a/modules/modkit/modkit.go b/modules/modkit/modkit.go
new file mode 100644
index 0000000..04606e8
--- /dev/null
+++ b/modules/modkit/modkit.go
@@ -0,0 +1,32 @@
+// Package modkit — общее для служб модулей bj (bj-npr, bj-quikexport, …).
+//
+// Службы модулей слушают только локальный адрес и принимают запросы только от bj-server:
+// он проверяет вход, права и лицензию и передаёт служебный ключ в X-BJ-Module-Key
+// (файл /var/lib/bj/.bj/module.key создаёт bj-server, службы читают его от того же пользователя).
+package modkit
+
+import (
+ "crypto/subtle"
+ "net/http"
+ "os"
+ "strings"
+)
+
+// RequireKey — пропускает запросы только со служебным ключом (кроме /healthz).
+// Ключ читается при каждом запросе, поэтому его замена не требует перезапуска службы.
+func RequireKey(keyPath string, next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path == "/healthz" {
+ next.ServeHTTP(w, r)
+ return
+ }
+ key, err := os.ReadFile(keyPath)
+ want := strings.TrimSpace(string(key))
+ got := r.Header.Get("X-BJ-Module-Key")
+ if err != nil || want == "" || subtle.ConstantTimeCompare([]byte(want), []byte(got)) != 1 {
+ http.Error(w, "модуль принимает запросы только от bj-server", http.StatusForbidden)
+ return
+ }
+ next.ServeHTTP(w, r)
+ })
+}
diff --git a/modules/npr/npr.go b/modules/npr/npr.go
index 684eeab..42ac68b 100644
--- a/modules/npr/npr.go
+++ b/modules/npr/npr.go
@@ -11,7 +11,6 @@ package npr
import (
"bytes"
"context"
- "crypto/subtle"
"embed"
"encoding/csv"
"encoding/json"
@@ -29,6 +28,8 @@ import (
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
+
+ "git.zetit.ru/zuevav/Bridge-and-Join-s/modules/modkit"
)
//go:embed web/*.html
@@ -148,24 +149,6 @@ func (s *Server) db() (*pgxpool.Pool, error) {
return p, nil
}
-// requireKey — принимать запросы только от bj-server (кроме /healthz).
-func (s *Server) requireKey(next http.Handler) http.Handler {
- return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- if r.URL.Path == "/healthz" {
- next.ServeHTTP(w, r)
- return
- }
- key, err := os.ReadFile(s.cfg.KeyPath)
- want := strings.TrimSpace(string(key))
- got := r.Header.Get("X-BJ-Module-Key")
- if err != nil || want == "" || subtle.ConstantTimeCompare([]byte(want), []byte(got)) != 1 {
- http.Error(w, "модуль принимает запросы только от bj-server", http.StatusForbidden)
- return
- }
- next.ServeHTTP(w, r)
- })
-}
-
// page — полная страница фрагментом для макета bj-server.
func (s *Server) page(w http.ResponseWriter, t *template.Template, name, title string, data any) {
var buf bytes.Buffer
@@ -597,5 +580,5 @@ func (s *Server) Handler() http.Handler {
[]period{{1, "за 1 час"}, {6, "за 6 часов"}, {24, "за сутки"}, {168, "за 7 дней"}, {720, "за 30 дней"}}}
s.page(w, s.client, "npr_client", "Клиент "+code+" · НПР / УДС", data)
})
- return s.requireKey(mux)
+ return modkit.RequireKey(s.cfg.KeyPath, mux)
}
diff --git a/internal/lkgateway/quikexport.go b/modules/quikexport/quikexport.go
similarity index 87%
rename from internal/lkgateway/quikexport.go
rename to modules/quikexport/quikexport.go
index c7ea815..ef3186a 100644
--- a/internal/lkgateway/quikexport.go
+++ b/modules/quikexport/quikexport.go
@@ -1,4 +1,4 @@
-package lkgateway
+package quikexport
// Модуль «Экспорт биржевой информации» — управление ARQA quikexport из bj:
// ключи QUIK, логин/пароль, где держим базу (наш PostgreSQL или внешний),
@@ -6,7 +6,10 @@ package lkgateway
// свободное место и доступ для внешних систем. Файлы экспортёра принадлежат
// arqasrv, служба — systemd, поэтому запись и перезапуск идут через root-воркер
// (bj-keymedia-worker → /usr/local/sbin/bj-quikexport-admin), а чтение состояния
-// (systemctl show, размеры логов, статистика базы) bj делает сам.
+// (systemctl show, размеры логов, статистика базы) служба делает сама.
+//
+// Работает отдельной службой bj-quikexport (cmd/bj-quikexport, server.go), обновляется
+// независимо от ядра; раздел настроек показывает bj-server на странице «Настройка».
//
// Лицензионный признак модуля: quik-export.
@@ -147,14 +150,6 @@ func (s QuikExportSettings) RODSN(withPassword bool) string {
return fmt.Sprintf("postgres://%s:%s@%s:%d/%s", s.ROUser, pw, s.dbHost(), s.DBPort, s.DBName)
}
-func (r *RuntimeConfig) UpdateQuikExport(s QuikExportSettings) error {
- r.mu.Lock()
- r.data.QuikExport = s
- r.data.UpdatedAt = time.Now().UTC()
- r.mu.Unlock()
- return r.save()
-}
-
// ---------- каталог таблиц экспорта ----------
// quikExportTable — одна таблица экспортёра ([Output tables] в export.ini).
@@ -456,7 +451,7 @@ func quikExportRenderODBC(s QuikExportSettings) string {
func quikExportRootOp(ctx context.Context, action, staging, extra string) (map[string]any, error) {
ctx, cancel := context.WithTimeout(ctx, 3*time.Minute)
defer cancel()
- return runKeymediaOp(ctx, "quikexport-"+action, map[string]string{"staging": staging, "extra": extra})
+ return rootOp(ctx, "quikexport-"+action, map[string]string{"staging": staging, "extra": extra})
}
func quikExportNewStaging() (string, error) {
@@ -468,8 +463,8 @@ func quikExportNewStaging() (string, error) {
}
// quikExportApply — собирает файлы, кладёт в staging и просит root установить их.
-func quikExportApply(ctx context.Context, rc *RuntimeConfig, restart bool) (map[string]any, error) {
- s := rc.Snapshot().QuikExport.withDefaults()
+func quikExportApply(ctx context.Context, st *Store, restart bool) (map[string]any, error) {
+ s := st.Get()
if s.QuikHost == "" || s.QuikUser == "" {
return nil, fmt.Errorf("не заданы сервер QUIK и логин")
}
@@ -514,17 +509,17 @@ func quikExportApply(ctx context.Context, rc *RuntimeConfig, restart bool) (map[
return res, err
}
s.AppliedAt = time.Now()
- _ = rc.UpdateQuikExport(s)
+ _ = st.Save(s)
return res, nil
}
// quikExportImport — читает текущие export.ini/info.ini экспортёра и заполняет настройки.
-func quikExportImport(ctx context.Context, rc *RuntimeConfig) (string, error) {
+func quikExportImport(ctx context.Context, st *Store) (string, error) {
res, err := quikExportRootOp(ctx, "dump", "", "")
if err != nil {
return "", err
}
- s := rc.Snapshot().QuikExport.withDefaults()
+ s := st.Get()
dec := func(k string) string {
v, _ := res[k].(string)
b, _ := base64.StdEncoding.DecodeString(v)
@@ -610,7 +605,7 @@ func quikExportImport(ctx context.Context, rc *RuntimeConfig) (string, error) {
}
}
s.Imported = true
- if err := rc.UpdateQuikExport(s); err != nil {
+ if err := st.Save(s); err != nil {
return "", err
}
return fmt.Sprintf("сервер QUIK %s:%d, пользователь %s, таблиц включено %d (%s), период портфелей %d с", s.QuikHost, s.QuikPort, s.QuikUser, len(s.Tables), s.Preset, s.PortfolioPeriod), nil
@@ -737,10 +732,13 @@ func diskUsage(path string) diskInfo {
// quikExportDBStats — объём базы и скорость роста (логином только для чтения).
type quikExportDBStats struct {
- OK bool
- Error string
- DBSizeMB float64
- Tables []struct{ Name, Size string; Rows int64 }
+ OK bool
+ Error string
+ DBSizeMB float64
+ Tables []struct {
+ Name, Size string
+ Rows int64
+ }
HistoryRows int64
HistoryFrom time.Time
Rows24h int64
@@ -805,7 +803,10 @@ func quikExportStats(s QuikExportSettings) quikExportDBStats {
rows, err := p.Query(ctx, `SELECT s.schemaname||'.'||s.relname, pg_size_pretty(pg_total_relation_size(s.relid)), COALESCE((SELECT reltuples::bigint FROM pg_class WHERE oid=s.relid),0) FROM pg_stat_user_tables s ORDER BY pg_total_relation_size(s.relid) DESC LIMIT 8`)
if err == nil {
for rows.Next() {
- var t struct{ Name, Size string; Rows int64 }
+ var t struct {
+ Name, Size string
+ Rows int64
+ }
if rows.Scan(&t.Name, &t.Size, &t.Rows) == nil {
st.Tables = append(st.Tables, t)
}
@@ -834,27 +835,27 @@ func quikExportStats(s QuikExportSettings) quikExportDBStats {
// QuikExportView — данные раздела настроек.
type QuikExportView struct {
- S QuikExportSettings
- Licensed bool
- Service quikExportServiceState
- Files []quikExportFileInfo // export.ini, info.ini, ключи
- Logs []quikExportFileInfo
- LogsMB float64
- QAPIMB float64
- DiskDB diskInfo
- DiskExp diskInfo
- DB quikExportDBStats
- LogTail []string
- ErrTail []string
- Tables []quikExportTable
- Groups []string
- Presets []struct{ Key, Title, Desc string }
- Enabled map[string]bool
- RODSN string
- RODSNFull string
- ShowRO bool
- ServerIP string
- Warnings []string
+ S QuikExportSettings
+ Licensed bool
+ Service quikExportServiceState
+ Files []quikExportFileInfo // export.ini, info.ini, ключи
+ Logs []quikExportFileInfo
+ LogsMB float64
+ QAPIMB float64
+ DiskDB diskInfo
+ DiskExp diskInfo
+ DB quikExportDBStats
+ LogTail []string
+ ErrTail []string
+ Tables []quikExportTable
+ Groups []string
+ Presets []struct{ Key, Title, Desc string }
+ Enabled map[string]bool
+ RODSN string
+ RODSNFull string
+ ShowRO bool
+ ServerIP string
+ Warnings []string
}
var (
@@ -863,16 +864,16 @@ var (
qxViewLast QuikExportView
)
-func quikExportView(rc *RuntimeConfig, showRO bool) QuikExportView {
+func quikExportView(st *Store, licensed, showRO bool) QuikExportView {
qxViewMu.Lock()
defer qxViewMu.Unlock()
- if time.Since(qxViewAt) < 15*time.Second && qxViewLast.S.Dir != "" {
+ if time.Since(qxViewAt) < 15*time.Second && qxViewLast.S.Dir != "" && qxViewLast.Licensed == licensed {
v := qxViewLast
v.ShowRO = showRO
return v
}
- s := rc.Snapshot().QuikExport.withDefaults()
- v := QuikExportView{S: s, Licensed: moduleAllowed(rc, quikExportFeature), Tables: quikExportTables, Presets: quikExportPresets, ShowRO: showRO}
+ s := st.Get()
+ v := QuikExportView{S: s, Licensed: licensed, Tables: quikExportTables, Presets: quikExportPresets, ShowRO: showRO}
v.Enabled = map[string]bool{}
for _, t := range s.Tables {
v.Enabled[t] = true
@@ -980,12 +981,12 @@ func quikExportServerIP() string {
// ---------- обработчики ----------
-func (h *setupHandlers) saveQuikExport(w http.ResponseWriter, r *http.Request) {
+func (srv *Server) saveQuikExport(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
- s := h.rc.Snapshot().QuikExport.withDefaults()
+ s := srv.store.Get()
f := r.FormValue
atoi := func(v string, def int) int {
if n, err := strconv.Atoi(strings.TrimSpace(v)); err == nil && n >= 0 {
@@ -1043,28 +1044,28 @@ func (h *setupHandlers) saveQuikExport(w http.ResponseWriter, r *http.Request) {
s.Enabled = f("enabled") != ""
}
s = s.withDefaults()
- if err := h.rc.UpdateQuikExport(s); err != nil {
- setupFlash(w, r, "Экспорт QUIK: ошибка сохранения: "+err.Error())
+ if err := srv.store.Save(s); err != nil {
+ flash(w, "Экспорт QUIK: ошибка сохранения: "+err.Error())
return
}
quikExportInvalidate()
- setupFlash(w, r, "Экспорт QUIK: настройки сохранены. Чтобы они попали в экспортёр, нажмите «Применить настройки и перезапустить».")
+ flash(w, "Экспорт QUIK: настройки сохранены. Чтобы они попали в экспортёр, нажмите «Применить настройки и перезапустить».")
}
-func (h *setupHandlers) uploadQuikExportKeys(w http.ResponseWriter, r *http.Request) {
+func (srv *Server) uploadQuikExportKeys(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
if err := r.ParseMultipartForm(4 << 20); err != nil {
- setupFlash(w, r, "Экспорт QUIK: не удалось прочитать файлы: "+err.Error())
+ flash(w, "Экспорт QUIK: не удалось прочитать файлы: "+err.Error())
return
}
if err := os.MkdirAll(quikExportKeysDir, 0o700); err != nil {
- setupFlash(w, r, "Экспорт QUIK: "+err.Error())
+ flash(w, "Экспорт QUIK: "+err.Error())
return
}
- s := h.rc.Snapshot().QuikExport.withDefaults()
+ s := srv.store.Get()
var saved []string
for _, field := range []string{"pubring", "secring"} {
file, hdr, err := r.FormFile(field)
@@ -1078,13 +1079,13 @@ func (h *setupHandlers) uploadQuikExportKeys(w http.ResponseWriter, r *http.Requ
}
name := field + ".txk"
if err := os.WriteFile(filepath.Join(quikExportKeysDir, name), b, 0o600); err != nil {
- setupFlash(w, r, "Экспорт QUIK: запись ключа: "+err.Error())
+ flash(w, "Экспорт QUIK: запись ключа: "+err.Error())
return
}
saved = append(saved, fmt.Sprintf("%s (%s, %d байт)", name, hdr.Filename, len(b)))
}
if len(saved) == 0 {
- setupFlash(w, r, "Экспорт QUIK: файлы ключей не выбраны.")
+ flash(w, "Экспорт QUIK: файлы ключей не выбраны.")
return
}
s.KeysAt = time.Now()
@@ -1094,24 +1095,24 @@ func (h *setupHandlers) uploadQuikExportKeys(w http.ResponseWriter, r *http.Requ
s.KeysNames = append(s.KeysNames, k)
}
}
- _ = h.rc.UpdateQuikExport(s)
+ _ = srv.store.Save(s)
quikExportInvalidate()
- setupFlash(w, r, "Экспорт QUIK: ключи загружены — "+strings.Join(saved, ", ")+". Они будут установлены в каталог экспортёра при «Применить настройки».")
+ flash(w, "Экспорт QUIK: ключи загружены — "+strings.Join(saved, ", ")+". Они будут установлены в каталог экспортёра при «Применить настройки».")
}
-func (h *setupHandlers) applyQuikExport(w http.ResponseWriter, r *http.Request) {
+func (srv *Server) applyQuikExport(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
- if !moduleAllowed(h.rc, quikExportFeature) {
- setupFlash(w, r, "Экспорт QUIK: модуль не входит в лицензию.")
+ if !licensed(r) {
+ flash(w, "Экспорт QUIK: модуль не входит в лицензию.")
return
}
- res, err := quikExportApply(r.Context(), h.rc, true)
+ res, err := quikExportApply(r.Context(), srv.store, true)
quikExportInvalidate()
if err != nil {
- setupFlash(w, r, "Экспорт QUIK: применить не удалось — "+err.Error())
+ flash(w, "Экспорт QUIK: применить не удалось — "+err.Error())
return
}
applied, _ := res["applied"].([]any)
@@ -1119,24 +1120,24 @@ func (h *setupHandlers) applyQuikExport(w http.ResponseWriter, r *http.Request)
for _, a := range applied {
names = append(names, fmt.Sprint(a))
}
- setupFlash(w, r, fmt.Sprintf("Экспорт QUIK: установлено %s; резервная копия %v; служба: %v.", strings.Join(names, ", "), res["backup"], res["service"]))
+ flash(w, fmt.Sprintf("Экспорт QUIK: установлено %s; резервная копия %v; служба: %v.", strings.Join(names, ", "), res["backup"], res["service"]))
}
-func (h *setupHandlers) importQuikExport(w http.ResponseWriter, r *http.Request) {
+func (srv *Server) importQuikExport(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
- msg, err := quikExportImport(r.Context(), h.rc)
+ msg, err := quikExportImport(r.Context(), srv.store)
quikExportInvalidate()
if err != nil {
- setupFlash(w, r, "Экспорт QUIK: импорт не удался — "+err.Error())
+ flash(w, "Экспорт QUIK: импорт не удался — "+err.Error())
return
}
- setupFlash(w, r, "Экспорт QUIK: настройки прочитаны из экспортёра — "+msg)
+ flash(w, "Экспорт QUIK: настройки прочитаны из экспортёра — "+msg)
}
-func (h *setupHandlers) serviceQuikExport(w http.ResponseWriter, r *http.Request) {
+func (srv *Server) serviceQuikExport(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
@@ -1145,35 +1146,35 @@ func (h *setupHandlers) serviceQuikExport(w http.ResponseWriter, r *http.Request
switch op {
case "start", "stop", "restart", "enable":
default:
- setupFlash(w, r, "Экспорт QUIK: неизвестная операция.")
+ flash(w, "Экспорт QUIK: неизвестная операция.")
return
}
_, err := quikExportRootOp(r.Context(), "service", "", op)
quikExportInvalidate()
if err != nil {
- setupFlash(w, r, "Экспорт QUIK: служба — "+err.Error())
+ flash(w, "Экспорт QUIK: служба — "+err.Error())
return
}
- setupFlash(w, r, "Экспорт QUIK: операция «"+op+"» выполнена.")
+ flash(w, "Экспорт QUIK: операция «"+op+"» выполнена.")
}
-func (h *setupHandlers) dbInitQuikExport(w http.ResponseWriter, r *http.Request) {
+func (srv *Server) dbInitQuikExport(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
- s := h.rc.Snapshot().QuikExport.withDefaults()
+ s := srv.store.Get()
if s.DBMode == "external" {
- setupFlash(w, r, "Экспорт QUIK: для внешней базы создайте базу и роли на том сервере (SQL-скрипты: /admin/setup/quikexport/sql).")
+ flash(w, "Экспорт QUIK: для внешней базы создайте базу и роли на том сервере (SQL-скрипты: /admin/setup/quikexport/sql).")
return
}
if s.DBPassword == "" || s.ROPassword == "" {
- setupFlash(w, r, "Экспорт QUIK: задайте пароли пользователя экспорта и логина только для чтения.")
+ flash(w, "Экспорт QUIK: задайте пароли пользователя экспорта и логина только для чтения.")
return
}
dir, err := quikExportNewStaging()
if err != nil {
- setupFlash(w, r, "Экспорт QUIK: "+err.Error())
+ flash(w, "Экспорт QUIK: "+err.Error())
return
}
defer os.RemoveAll(dir)
@@ -1186,15 +1187,15 @@ func (h *setupHandlers) dbInitQuikExport(w http.ResponseWriter, r *http.Request)
res, err := quikExportRootOp(r.Context(), "db-init", dir, "")
quikExportInvalidate()
if err != nil {
- setupFlash(w, r, "Экспорт QUIK: база — "+err.Error())
+ flash(w, "Экспорт QUIK: база — "+err.Error())
return
}
- setupFlash(w, r, fmt.Sprintf("Экспорт QUIK: база готова — %v", res["steps"]))
+ flash(w, fmt.Sprintf("Экспорт QUIK: база готова — %v", res["steps"]))
}
// sqlQuikExport — отдаёт SQL для ручного создания базы на внешнем сервере.
-func (h *setupHandlers) sqlQuikExport(w http.ResponseWriter, r *http.Request) {
- s := h.rc.Snapshot().QuikExport.withDefaults()
+func (srv *Server) sqlQuikExport(w http.ResponseWriter, r *http.Request) {
+ s := srv.store.Get()
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.Header().Set("Content-Disposition", `attachment; filename="quik_export_setup.sql"`)
fmt.Fprintf(w, "-- База экспорта QUIK для bj. Выполнить на сервере PostgreSQL %s:%d от суперпользователя.\n", s.DBHost, s.DBPort)
@@ -1208,8 +1209,8 @@ func (h *setupHandlers) sqlQuikExport(w http.ResponseWriter, r *http.Request) {
}
// previewQuikExport — показать export.ini/info.ini, которые будут установлены (пароли скрыты).
-func (h *setupHandlers) previewQuikExport(w http.ResponseWriter, r *http.Request) {
- s := h.rc.Snapshot().QuikExport.withDefaults()
+func (srv *Server) previewQuikExport(w http.ResponseWriter, r *http.Request) {
+ s := srv.store.Get()
exp, err := quikExportRenderExportINI(s)
if err != nil {
http.Error(w, err.Error(), 500)
@@ -1227,14 +1228,3 @@ func (h *setupHandlers) previewQuikExport(w http.ResponseWriter, r *http.Request
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
fmt.Fprintf(w, "===== info.ini =====\n%s\n\n===== /etc/odbc.ini (секция) =====\n%s\n\n===== export.ini =====\n%s", mask(inf), quikExportRenderODBC(s), mask(exp))
}
-
-func registerQuikExportSetup(mux *http.ServeMux, h *setupHandlers) {
- mux.HandleFunc("/admin/setup/quikexport", h.saveQuikExport)
- mux.HandleFunc("/admin/setup/quikexport/keys", h.uploadQuikExportKeys)
- mux.HandleFunc("/admin/setup/quikexport/apply", h.applyQuikExport)
- mux.HandleFunc("/admin/setup/quikexport/import", h.importQuikExport)
- mux.HandleFunc("/admin/setup/quikexport/service", h.serviceQuikExport)
- mux.HandleFunc("/admin/setup/quikexport/dbinit", h.dbInitQuikExport)
- mux.HandleFunc("/admin/setup/quikexport/sql", h.sqlQuikExport)
- mux.HandleFunc("/admin/setup/quikexport/preview", h.previewQuikExport)
-}
diff --git a/modules/quikexport/server.go b/modules/quikexport/server.go
new file mode 100644
index 0000000..b89097e
--- /dev/null
+++ b/modules/quikexport/server.go
@@ -0,0 +1,236 @@
+package quikexport
+
+// Служба bj-quikexport: свои настройки, раздел настроек фрагментом для bj-server и действия раздела.
+//
+// Протокол с bj-server (всё со служебным ключом, см. modkit):
+// - GET /section[?reveal_ro=1] — HTML раздела «Экспорт QUIK» для страницы «Настройка»,
+// в заголовке X-BJ-Indicator — цвет индикатора меню (ok | warn | err | пусто);
+// - POST /admin/setup/quikexport[/keys|apply|import|service|dbinit] — действие; итог — 204
+// с сообщением в X-BJ-Flash, bj-server показывает его как обычное сообщение страницы настроек;
+// - GET /admin/setup/quikexport/sql и /preview — файлы как есть.
+//
+// Лицензию проверяет bj-server и передаёт в X-BJ-Licensed: 1.
+
+import (
+ "bytes"
+ "context"
+ "embed"
+ "encoding/json"
+ "fmt"
+ "html/template"
+ "log"
+ "net/http"
+ "net/url"
+ "os"
+ "path/filepath"
+ "strings"
+ "sync"
+ "time"
+
+ "git.zetit.ru/zuevav/Bridge-and-Join-s/modules/modkit"
+)
+
+//go:embed web/section.html
+var sectionFS embed.FS
+
+// Config — где служба берёт настройки и ключ.
+type Config struct {
+ StorePath string // собственные настройки модуля
+ SetupPath string // setup.json bj: отсюда настройки переносятся при первом запуске (раздел quik_export)
+ KeyPath string // служебный ключ модулей
+}
+
+// Server — служба модуля.
+type Server struct {
+ cfg Config
+ store *Store
+ section *template.Template
+}
+
+// New — служба с разобранным шаблоном; при первом запуске переносит настройки из setup.json.
+func New(cfg Config) (*Server, error) {
+ t, err := template.New("").Funcs(template.FuncMap{
+ "msk": func(t time.Time) string {
+ if t.IsZero() {
+ return "—"
+ }
+ return t.In(time.FixedZone("МСК", 3*3600)).Format("02.01.2006 15:04:05")
+ },
+ }).ParseFS(sectionFS, "web/section.html")
+ if err != nil {
+ return nil, err
+ }
+ st := &Store{path: cfg.StorePath}
+ moved, err := st.migrate(cfg.SetupPath)
+ if err != nil {
+ return nil, fmt.Errorf("перенос настроек из %s: %w", cfg.SetupPath, err)
+ }
+ if moved {
+ log.Printf("bj-quikexport: настройки перенесены из %s в %s", cfg.SetupPath, cfg.StorePath)
+ }
+ return &Server{cfg: cfg, store: st, section: t}, nil
+}
+
+// Handler — все маршруты службы; без служебного ключа отвечает 403.
+func (srv *Server) Handler() http.Handler {
+ mux := http.NewServeMux()
+ mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte("ok")) })
+ mux.HandleFunc("/section", func(w http.ResponseWriter, r *http.Request) {
+ v := quikExportView(srv.store, licensed(r), r.URL.Query().Get("reveal_ro") == "1")
+ var buf bytes.Buffer
+ if err := srv.section.ExecuteTemplate(&buf, "section", v); err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ w.Header().Set("Cache-Control", "no-store")
+ w.Header().Set("X-BJ-Indicator", indicator(v))
+ _, _ = w.Write(buf.Bytes())
+ })
+ mux.HandleFunc("/admin/setup/quikexport", srv.saveQuikExport)
+ mux.HandleFunc("/admin/setup/quikexport/keys", srv.uploadQuikExportKeys)
+ mux.HandleFunc("/admin/setup/quikexport/apply", srv.applyQuikExport)
+ mux.HandleFunc("/admin/setup/quikexport/import", srv.importQuikExport)
+ mux.HandleFunc("/admin/setup/quikexport/service", srv.serviceQuikExport)
+ mux.HandleFunc("/admin/setup/quikexport/dbinit", srv.dbInitQuikExport)
+ mux.HandleFunc("/admin/setup/quikexport/sql", srv.sqlQuikExport)
+ mux.HandleFunc("/admin/setup/quikexport/preview", srv.previewQuikExport)
+ return modkit.RequireKey(srv.cfg.KeyPath, mux)
+}
+
+func licensed(r *http.Request) bool { return r.Header.Get("X-BJ-Licensed") == "1" }
+
+// flash — итог действия для страницы настроек bj.
+func flash(w http.ResponseWriter, msg string) {
+ w.Header().Set("X-BJ-Flash", url.PathEscape(msg))
+ w.WriteHeader(http.StatusNoContent)
+}
+
+// indicator — цвет индикатора раздела в меню настроек.
+func indicator(v QuikExportView) string {
+ switch {
+ case !v.Licensed:
+ return "err"
+ case v.S.Enabled && v.Service.Active == "active":
+ return "ok"
+ case v.S.Enabled:
+ return "warn"
+ }
+ return ""
+}
+
+// ---- настройки ----
+
+// Store — настройки модуля в собственном файле (bj-server перезаписывает setup.json целиком,
+// поэтому служба туда не пишет).
+type Store struct {
+ path string
+ mu sync.Mutex
+}
+
+// Get — настройки со значениями по умолчанию.
+func (st *Store) Get() QuikExportSettings {
+ st.mu.Lock()
+ defer st.mu.Unlock()
+ var s QuikExportSettings
+ if b, err := os.ReadFile(st.path); err == nil {
+ _ = json.Unmarshal(b, &s)
+ }
+ return s.withDefaults()
+}
+
+// Save — атомарная запись (0600: в настройках пароли).
+func (st *Store) Save(s QuikExportSettings) error {
+ st.mu.Lock()
+ defer st.mu.Unlock()
+ raw, err := json.MarshalIndent(s, "", " ")
+ if err != nil {
+ return err
+ }
+ if err := os.MkdirAll(filepath.Dir(st.path), 0o700); err != nil {
+ return err
+ }
+ tmp := st.path + ".tmp"
+ if err := os.WriteFile(tmp, raw, 0o600); err != nil {
+ return err
+ }
+ return os.Rename(tmp, st.path)
+}
+
+// migrate — если своих настроек ещё нет, берёт раздел quik_export из setup.json bj.
+func (st *Store) migrate(setupPath string) (bool, error) {
+ if _, err := os.Stat(st.path); err == nil || setupPath == "" {
+ return false, nil
+ }
+ b, err := os.ReadFile(setupPath)
+ if err != nil {
+ return false, nil
+ }
+ var sj struct {
+ QuikExport json.RawMessage `json:"quik_export"`
+ }
+ if json.Unmarshal(b, &sj) != nil || len(sj.QuikExport) == 0 || string(sj.QuikExport) == "null" {
+ return false, nil
+ }
+ var s QuikExportSettings
+ if err := json.Unmarshal(sj.QuikExport, &s); err != nil {
+ return false, err
+ }
+ return true, st.Save(s)
+}
+
+// ---- root-воркер ----
+
+const keymediaReqDir = "/var/lib/bj/keymedia-requests"
+
+// rootOp — запрос root-воркеру bj-keymedia-worker через файловую очередь и ожидание ответа.
+func rootOp(ctx context.Context, action string, fields map[string]string) (map[string]any, error) {
+ id := fmt.Sprintf("%s-%d", action, time.Now().UnixNano())
+ reqPath := filepath.Join(keymediaReqDir, id+".req")
+ resPath := filepath.Join(keymediaReqDir, id+".res")
+ defer os.Remove(resPath)
+
+ req := map[string]string{"action": action}
+ for k, v := range fields {
+ req[k] = v
+ }
+ reqBody, _ := json.Marshal(req)
+ // Пишем атомарно (tmp → rename), чтобы воркер не подхватил полупустой файл.
+ tmp := reqPath + ".tmp"
+ if err := os.WriteFile(tmp, reqBody, 0o660); err != nil {
+ return nil, fmt.Errorf("запись запроса: %w", err)
+ }
+ if err := os.Rename(tmp, reqPath); err != nil {
+ return nil, fmt.Errorf("публикация запроса: %w", err)
+ }
+
+ ticker := time.NewTicker(200 * time.Millisecond)
+ defer ticker.Stop()
+ for {
+ select {
+ case <-ctx.Done():
+ return nil, fmt.Errorf("таймаут ожидания root-воркера")
+ case <-ticker.C:
+ b, err := os.ReadFile(resPath)
+ if err != nil {
+ continue // ещё не готово
+ }
+ res := map[string]any{}
+ // systemd/скрипты могут напечатать предупреждение перед JSON — берём с первой «{».
+ if i := bytes.IndexByte(b, '{'); i > 0 {
+ b = b[i:]
+ }
+ if jerr := json.Unmarshal(b, &res); jerr != nil {
+ return nil, fmt.Errorf("разбор ответа воркера: %v (%s)", jerr, strings.TrimSpace(string(b)))
+ }
+ if res["ok"] != true {
+ msg, _ := res["error"].(string)
+ if msg == "" {
+ msg = "ошибка"
+ }
+ return res, fmt.Errorf("воркер: %s", msg)
+ }
+ return res, nil
+ }
+ }
+}
diff --git a/modules/quikexport/server_test.go b/modules/quikexport/server_test.go
new file mode 100644
index 0000000..96f8eb1
--- /dev/null
+++ b/modules/quikexport/server_test.go
@@ -0,0 +1,74 @@
+package quikexport
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+const testKey = "k-0123456789abcdef0123456789abcdef"
+
+func TestServiceSectionAndSave(t *testing.T) {
+ dir := t.TempDir()
+ keyPath := filepath.Join(dir, "module.key")
+ setup := filepath.Join(dir, "setup.json")
+ store := filepath.Join(dir, "quik-export", "settings.json")
+ if err := os.WriteFile(keyPath, []byte(testKey+"\n"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(setup, []byte(`{"quik_export":{"enabled":true,"quik_host":"10.0.0.1","quik_user":"exp","preset":"npr"}}`), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ srv, err := New(Config{StorePath: store, SetupPath: setup, KeyPath: keyPath})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got := srv.store.Get(); got.QuikHost != "10.0.0.1" || !got.Enabled || got.QuikPort != 15100 {
+ t.Fatalf("перенос настроек из setup.json: %+v", got)
+ }
+ h := srv.Handler()
+
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/section", nil))
+ if rec.Code != http.StatusForbidden {
+ t.Fatalf("без ключа: код %d", rec.Code)
+ }
+
+ req := httptest.NewRequest(http.MethodGet, "/section", nil)
+ req.Header.Set("X-BJ-Module-Key", testKey)
+ req.Header.Set("X-BJ-Licensed", "1")
+ rec = httptest.NewRecorder()
+ h.ServeHTTP(rec, req)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("раздел: код %d: %s", rec.Code, rec.Body.String())
+ }
+ body := rec.Body.String()
+ for _, want := range []string{"Подключение к серверу QUIK", `value="10.0.0.1"`, "/admin/setup/quikexport/apply"} {
+ if !strings.Contains(body, want) {
+ t.Errorf("в разделе нет %q", want)
+ }
+ }
+ if ind := rec.Header().Get("X-BJ-Indicator"); ind == "err" {
+ t.Errorf("индикатор при действующей лицензии: %q", ind)
+ }
+
+ form := url.Values{"part": {"quik"}, "quik_host": {"10.0.0.2"}, "quik_user": {"exp"}, "section": {"sec-quikexport"}}
+ req = httptest.NewRequest(http.MethodPost, "/admin/setup/quikexport", strings.NewReader(form.Encode()))
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ req.Header.Set("X-BJ-Module-Key", testKey)
+ rec = httptest.NewRecorder()
+ h.ServeHTTP(rec, req)
+ if rec.Code != http.StatusNoContent || rec.Header().Get("X-BJ-Flash") == "" {
+ t.Fatalf("сохранение: код %d, flash %q", rec.Code, rec.Header().Get("X-BJ-Flash"))
+ }
+ if got := srv.store.Get(); got.QuikHost != "10.0.0.2" {
+ t.Fatalf("после сохранения quik_host = %q", got.QuikHost)
+ }
+ if fi, err := os.Stat(store); err != nil || fi.Mode().Perm() != 0o600 {
+ t.Fatalf("файл настроек: %v %v", fi, err)
+ }
+}
diff --git a/internal/lkgateway/web/quikexport/export.ini.tmpl b/modules/quikexport/web/quikexport/export.ini.tmpl
similarity index 100%
rename from internal/lkgateway/web/quikexport/export.ini.tmpl
rename to modules/quikexport/web/quikexport/export.ini.tmpl
diff --git a/internal/lkgateway/web/quikexport/exporter_pg.sql b/modules/quikexport/web/quikexport/exporter_pg.sql
similarity index 100%
rename from internal/lkgateway/web/quikexport/exporter_pg.sql
rename to modules/quikexport/web/quikexport/exporter_pg.sql
diff --git a/internal/lkgateway/web/quikexport/info.ini.tmpl b/modules/quikexport/web/quikexport/info.ini.tmpl
similarity index 100%
rename from internal/lkgateway/web/quikexport/info.ini.tmpl
rename to modules/quikexport/web/quikexport/info.ini.tmpl
diff --git a/internal/lkgateway/web/quikexport/npr_migration.sql b/modules/quikexport/web/quikexport/npr_migration.sql
similarity index 100%
rename from internal/lkgateway/web/quikexport/npr_migration.sql
rename to modules/quikexport/web/quikexport/npr_migration.sql
diff --git a/modules/quikexport/web/section.html b/modules/quikexport/web/section.html
new file mode 100644
index 0000000..d5c8774
--- /dev/null
+++ b/modules/quikexport/web/section.html
@@ -0,0 +1,131 @@
+{{define "section"}}
+ {{$X := .}}{{$S := $X.S}}
+
QUIK — Экспорт биржевой информации
+
Модуль ARQA «Экспорт биржевой информации» выгружает данные сервера QUIK (портфели клиентов, позиции, сделки, справочники) в базу PostgreSQL. bj управляет им отсюда: ключи и логин QUIK, где держим базу, какой объём данных выгружаем, состояние службы и место на диске. На этих данных работают модуль «НПР / УДС» и дополнительные проверки M2M. Лицензионный признак quik-export.
+ {{range $X.Warnings}}
{{.}}
{{end}}
+
+
+
Состояние модуля
+
+
+
Служба quikexport
+
{{if not $X.Service.Installed}}не установлена{{else if eq $X.Service.Active "active"}}работает{{else}}{{$X.Service.Active}}{{end}}
+
{{if $X.Service.PID}}PID {{$X.Service.PID}} · {{end}}{{if $X.Service.MemoryMB}}память {{$X.Service.MemoryMB}} МБ · {{end}}автозапуск {{if $X.Service.Enabled}}да{{else}}нет{{end}}{{if $X.Service.Restarts}} · перезапусков {{$X.Service.Restarts}}{{end}}
+
Данные из QUIK
+ {{if $X.DB.OK}}
{{if $X.DB.LastTick.IsZero}}нет пульса{{else if lt $X.DB.TickAgeSec 180}}свежие{{else}}устарели{{end}}
+
{{if not $X.DB.LastTick.IsZero}}цикл {{msk $X.DB.LastTick}} ({{$X.DB.TickAgeSec}} с назад) · {{end}}клиентов {{$X.DB.Clients}}
+ {{else}}
база недоступна{{$X.DB.Error}}
{{end}}
+
Объём базы {{$S.DBName}}
+
{{printf "%.0f" $X.DB.DBSizeMB}} МБ
+
история НПР {{$X.DB.HistoryRows}} строк{{if not $X.DB.HistoryFrom.IsZero}} с {{$X.DB.HistoryFrom.Format "02.01.2006"}}{{end}} · рост ≈ {{printf "%.1f" $X.DB.GrowthMBDay}} МБ/сутки ({{$X.DB.Rows24h}} событий за 24 ч)
+
Диск {{if $X.DiskDB.OK}}базы данных{{else}}экспортёра{{end}}
+ {{with $X.DiskDB}}{{if .OK}}
свободно {{printf "%.1f" .FreeGB}} ГБ из {{printf "%.0f" .TotalGB}}занято {{.UsedPct}}% · {{.Path}}
{{end}}{{end}}
+ {{if not $X.DiskDB.OK}}{{with $X.DiskExp}}
свободно {{printf "%.1f" .FreeGB}} ГБ из {{printf "%.0f" .TotalGB}}занято {{.UsedPct}}%
{{end}}{{end}}
+
Логи и кэш экспортёра
+
{{printf "%.0f" $X.LogsMB}} МБ логов
+
{{range $X.Logs}}{{if .Exists}}{{.Name}} {{printf "%.0f" .SizeMB}} МБ · {{end}}{{end}}кэш QAPI {{printf "%.0f" $X.QAPIMB}} МБ · ротация: ежедневно / 200 МБ, 5 копий
+
+ {{if $X.DB.Tables}}
Крупнейшие таблицы базы
+ | Таблица | Размер | Строк (оценка) |
{{range $X.DB.Tables}}{{.Name}} | {{.Size}} | {{.Rows}} |
{{end}}
{{end}}
+
+
+
+ {{if and $X.Service.Installed (not $X.Service.Enabled)}}{{end}}
+
+ {{if $X.LogTail}}
Последние строки export.log{{if $X.ErrTail}} и export.err{{end}}
+ {{range $X.LogTail}}{{.}}
+{{end}}{{if $X.ErrTail}}--- export.err ---
+{{range $X.ErrTail}}{{.}}
+{{end}}{{end}}{{end}}
+
+
+
+
1. Подключение к серверу QUIK
+
Логин экспорта на сервере QUIK брокера (выдаёт администратор QUIK / ARQA) и пара ключей pubring.txk / secring.txk к нему. Одна учётная запись = одна сессия: второй экспортёр с тем же логином получит «Вы уже работаете в системе».
+
+
+
Ключи: {{if $S.KeysNames}}{{range $S.KeysNames}}{{.}} {{end}}{{if not $S.KeysAt.IsZero}}(загружены {{msk $S.KeysAt}}){{end}}{{else}}не загружены{{end}}.
+ В каталоге экспортёра: {{range $X.Files}}{{.Name}} {{if .Exists}}✓ {{.ModTime.Format "02.01 15:04"}}{{else}}✗{{end}} · {{end}}
+
+
+
+
2. База данных экспорта
+
Куда экспортёр пишет данные. Обычно — наш PostgreSQL на этом же сервере (bj создаст базу, роли и схему одной кнопкой). Внешний сервер — если базу держит заказчик. Логин только для чтения используют модуль «НПР / УДС», проверки M2M и внешние системы (Grafana, отчёты).
+
+
Доступ для внешних систем и других модулей: {{if $X.ShowRO}}{{$X.RODSNFull}}{{else}}{{$X.RODSN}}{{end}}
+ {{if $X.ShowRO}}скрыть пароль{{else}}показать пароль{{end}}{{if ne $S.DBMode "external"}} · снаружи: хост {{$X.ServerIP}}, доступ по IP открывает администратор (pg_hba){{end}}.
+
+
+
+
3. Объём данных
+
Какие таблицы QUIK выгружаем. Чем больше набор, тем больше нагрузка на сервер QUIK, база и диск. Портфели пересчитываются каждые «период портфелей» секунд (ARQA рекомендует не менее 60); история изменений НПР/УДС хранится в схеме npr.
+
+
+
+
+
4. Применить к экспортёру
+
bj собирает export.ini, info.ini, секцию ODBC и ключи из настроек выше и устанавливает их в каталог службы ({{$S.Dir}}) с резервной копией прежних файлов, настраивает ротацию логов и перезапускает экспортёр. {{if not $S.AppliedAt.IsZero}}Последнее применение: {{msk $S.AppliedAt}}.{{end}}{{if not $S.Imported}} Если экспортёр уже настроен вручную — сначала «Прочитать настройки из экспортёра».{{end}}
+
+
Установка на новом сервере: дистрибутив ARQA (install_quikexport-12.x.run, Linux) ставится установщиком bj в {{$S.Dir}}; нужны пакеты unixodbc, odbc-postgresql, libjemalloc2. Обновления экспортёра приходят через канал обновлений bj после проверки у нас.
+
+{{end}}
diff --git a/scripts/build-release.sh b/scripts/build-release.sh
index 6ac1e42..21fc7ad 100755
--- a/scripts/build-release.sh
+++ b/scripts/build-release.sh
@@ -6,7 +6,7 @@
# канал stable (по умолчанию) | beta | …
# опубликовать publish — сразу загрузить на сервер (нужен репозиторий lic_Bridge-and-Join-s рядом)
#
-# Версии компонентов: переменные BJ_SERVER_VERSION, INSTALLER_VERSION, COMPONENT_INSTALL_VERSION, NPR_VERSION,
+# Версии компонентов: переменные BJ_SERVER_VERSION, INSTALLER_VERSION, COMPONENT_INSTALL_VERSION, NPR_VERSION, QUIKEXPORT_VERSION,
# CRYPTO_VERSION (по умолчанию — версия релиза). Криптосервис берётся из CRYPTO_JAR
# (по умолчанию /opt/bj/crypto-service.jar). Ключ подписи: RELEASE_KEY (~/keys/release-signing.priv).
set -eu
@@ -26,6 +26,8 @@ CGO_ENABLED=0 go build -trimpath -ldflags "-s -w -X main.installerVersion=$(ver
CGO_ENABLED=0 go build -trimpath -ldflags "-s -w" -o "$DIST/bj-component-install" ./cmd/bj-component-install
CGO_ENABLED=0 go build -trimpath -ldflags "-s -w -X main.version=$(ver NPR_VERSION)" -o "$DIST/bj-npr" ./cmd/bj-npr
ver NPR_VERSION > "$DIST/bj-npr.version"
+CGO_ENABLED=0 go build -trimpath -ldflags "-s -w -X main.version=$(ver QUIKEXPORT_VERSION)" -o "$DIST/bj-quikexport" ./cmd/bj-quikexport
+ver QUIKEXPORT_VERSION > "$DIST/bj-quikexport.version"
ver BJ_SERVER_VERSION > "$DIST/bj-server.version"
ver INSTALLER_VERSION > "$DIST/bj-installer.version"
ver COMPONENT_INSTALL_VERSION > "$DIST/bj-component-install.version"