Экспорт QUIK — отдельная служба bj-quikexport; модули, выделенные из ядра, ставятся с обновлением
bj-quikexport (modules/quikexport + cmd/bj-quikexport, 127.0.0.1:8084): управление ARQA quikexport перенесено из bj-server. Настройки у службы в /var/lib/bj/.bj/quik-export/settings.json (при первом запуске переносятся из setup.json), раздел отдаётся фрагментом с индикатором меню, действия отвечают X-BJ-Flash. bj-server встраивает раздел в «Настройку», проксирует формы /admin/setup/quikexport/… и передаёт лицензию (X-BJ-Licensed). Общая проверка служебного ключа — modules/modkit (им пользуется и bj-npr), общее для прокси — lkgateway/modules.go. Выделенные из ядра модули (bj-npr, bj-quikexport: Component.FromCore) при обновлении ставятся и там, где их ещё нет, если входят в лицензию: release.MarkSplitModules, request.json "new", root-установщик ставит и включает unit-файл из комплекта. Если в обновлении меняется сам установщик, модули ставятся после перезапуска bj-server (ensureSplitModules). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user