Экспорт 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:
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 не выделялся из ядра")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user