feat(update): независимые обновления компонентов bj

- internal/release/components.go: список компонентов (ядро bj-server, криптосервис, bj-lk,
  bj-npr, bj-quikexport) с путём и службой, установленные версии в components.json,
  расчёт обновлений по версии каждого файла и sha256 (без отката, модули — только если
  уже установлены на сервере)
- cmd/bj-component-install: root-установщик — сам перепроверяет подпись манифеста ключом
  /etc/bj/update.pub и sha256, копия прежнего файла, перезапуск только затронутых служб
- updater: bj-server (без прав root) скачивает в update-staging и передаёт установку
  root-воркеру (действие component-install); установка в фоне
- настройки: таблица компонентов с версиями, «Обновить» и «Обновить всё»
- bj-release: версия файла из <файл>.version; bj-keymedia-worker: component-install

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
zuevav
2026-09-15 20:16:36 +03:00
parent 5115ed7223
commit 2908697363
11 changed files with 720 additions and 114 deletions
+146 -76
View File
@@ -1,10 +1,21 @@
package lkgateway
// Обновления bj по компонентам (internal/release/components.go). У каждого компонента своя версия в
// манифесте артефактории: ядро, криптосервис, ЛК клиента, НПР, экспорт QUIK обновляются независимо.
//
// bj-server работает без прав root, поэтому сам только проверяет манифест, скачивает выбранные
// файлы в /var/lib/bj/update-staging и передаёт установку root-воркеру (действие
// component-install → /usr/local/sbin/bj-component-install). Установщик заново проверяет подпись и
// sha256, ставит файлы и перезапускает службы; при обновлении ядра bj-server перезапустится сам.
import (
"context"
"encoding/json"
"fmt"
"log"
"os"
"path/filepath"
"strings"
"time"
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/release"
@@ -17,32 +28,25 @@ var BuildVersion = "0.1.0"
// DefaultUpdatePublicKey — публичный ключ артефактории, зашитый в релиз.
// Пустой в исходниках; подставляется при официальной сборке. Если задан в
// настройках (UpdateSettings.PublicKey) — приоритет у настроек.
// настройках (UpdateSettings.PublicKey) — приоритет у настроек (для проверки
// в bj; root-установщик верит только /etc/bj/update.pub).
var DefaultUpdatePublicKey = ""
// installPaths — куда устанавливать артефакты по логическому имени.
// Файлы не из этого списка при авто-обновлении пропускаются (скрипты/SQL
// обновляются отдельно, не на лету).
var installPaths = map[string]string{
"bj-server": "/opt/bj/bj-server",
"crypto-service.jar": "/opt/bj/crypto-service.jar",
}
const updateStagingDir = "/var/lib/bj/update-staging"
// Updater — авто-обновление bj-server из артефактории (работает поверх rc).
type Updater struct{ rc *RuntimeConfig }
// NewUpdater создаёт Updater на текущем runtime-конфиге.
func NewUpdater(rc *RuntimeConfig) *Updater { return &Updater{rc: rc} }
// UpdateStatus — сводка для UI/handler.
type UpdateStatus struct {
Configured bool
CurrentVersion string
Available string
HasUpdate bool
Available string // версия релиза
HasUpdate bool // есть что обновить хотя бы в одном компоненте
Channel string
Notes string
Message string
Components []release.ComponentState
}
func (u *Updater) updateClient() (*release.Client, error) {
@@ -61,8 +65,7 @@ func (u *Updater) updateClient() (*release.Client, error) {
return release.NewClient(cfg.BaseURL, channel, pub)
}
// CheckForUpdate скачивает манифест, проверяет подпись, сравнивает версии и
// сохраняет результат в настройки. Возвращает сводку.
// CheckForUpdate — манифест и состояние каждого компонента.
func (u *Updater) CheckForUpdate(ctx context.Context) (UpdateStatus, error) {
st := UpdateStatus{CurrentVersion: BuildVersion, Channel: u.rc.Snapshot().Update.Channel}
cl, err := u.updateClient()
@@ -75,92 +78,167 @@ func (u *Updater) CheckForUpdate(ctx context.Context) (UpdateStatus, error) {
m, err := cl.FetchManifest(ctx)
if err != nil {
st.Message = "проверка не удалась: " + err.Error()
u.saveCheckResult(st)
u.saveCheckResult(st, nil)
return st, err
}
st.Available = m.Version
st.Notes = m.Notes
st.HasUpdate = release.IsNewer(m.Version, BuildVersion)
if st.HasUpdate {
st.Message = fmt.Sprintf("доступна версия %s (текущая %s)", m.Version, BuildVersion)
} else {
st.Message = "установлена актуальная версия " + BuildVersion
st.Available, st.Notes = m.Version, m.Notes
st.Components = release.Plan("", m, release.LoadInstalled(release.InstalledFile), BuildVersion)
var upd []string
avail := map[string]string{}
for _, c := range st.Components {
if c.Available != "" {
avail[c.Name] = c.Available
}
if c.Update {
upd = append(upd, c.Name+" "+c.Available)
}
}
u.saveCheckResult(st)
st.HasUpdate = len(upd) > 0
if st.HasUpdate {
st.Message = "доступны обновления: " + strings.Join(upd, ", ")
} else {
st.Message = "все установленные компоненты актуальны (релиз " + m.Version + ")"
}
u.saveCheckResult(st, avail)
return st, nil
}
func (u *Updater) saveCheckResult(st UpdateStatus) {
func (u *Updater) saveCheckResult(st UpdateStatus, avail map[string]string) {
cfg := u.rc.Snapshot().Update
cfg.LastCheck = time.Now().UTC()
cfg.LastResult = st.Message
cfg.Available = st.Available
cfg.Notes = st.Notes
if avail != nil {
cfg.Components = avail
}
if err := u.rc.SaveUpdateSettings(cfg); err != nil {
log.Printf("lk-gateway: сохранение результата проверки обновления: %v", err)
}
}
// ApplyUpdate скачивает обновлённые артефакты (с проверкой подписи манифеста
// и sha256 каждого файла), атомарно заменяет бинари и завершает процесс с
// ненулевым кодом — systemd (Restart=on-failure) поднимает новую версию.
func (u *Updater) ApplyUpdate(ctx context.Context) error {
// Гейт лицензией: если лицензирование включено — требуется валидная
// лицензия с фичей updates. Без лицензирования (открытый режим) — пропускаем.
// ApplyUpdate — скачать выбранные компоненты (names пусто — все, для которых есть обновление) и
// передать установку root-установщику. Если среди них ядро, bj-server перезапустится до ответа.
func (u *Updater) ApplyUpdate(ctx context.Context, names []string) (string, error) {
if licensingEnabled(u.rc) {
ls := licenseStatus(u.rc)
if !ls.Valid {
return fmt.Errorf("обновления заблокированы — лицензия: %s", ls.Message)
return "", fmt.Errorf("обновления заблокированы — лицензия: %s", ls.Message)
}
if !ls.AllowsUpdates {
return fmt.Errorf("обновления не входят в план %q", ls.Plan)
return "", fmt.Errorf("обновления не входят в план %q", ls.Plan)
}
}
cl, err := u.updateClient()
if err != nil {
return err
return "", err
}
m, err := cl.FetchManifest(ctx)
sm, m, err := cl.FetchSigned(ctx)
if err != nil {
return fmt.Errorf("манифест: %w", err)
return "", fmt.Errorf("манифест: %w", err)
}
if !release.IsNewer(m.Version, BuildVersion) {
return fmt.Errorf("обновление не требуется (текущая %s, доступна %s)", BuildVersion, m.Version)
plan := release.Plan("", m, release.LoadInstalled(release.InstalledFile), BuildVersion)
want := map[string]bool{}
for _, n := range names {
want[n] = true
}
var selected []release.ComponentState
for _, c := range plan {
switch {
case len(want) > 0 && !want[c.Name]:
case c.Update:
selected = append(selected, c)
case want[c.Name]:
return "", fmt.Errorf("%s: обновление не требуется (установлена %s, в релизе %s)", c.Title, pickVersion(c.Installed), pickVersion(c.Available))
}
}
if len(selected) == 0 {
return "", fmt.Errorf("нечего обновлять: все установленные компоненты актуальны (релиз %s)", m.Version)
}
updated := 0
for _, a := range m.Artifacts {
dst, ok := installPaths[a.Name]
if !ok {
continue // скрипты/SQL не обновляем на лету
}
dir := dirOf(dst)
path, err := cl.DownloadArtifact(ctx, a, dir)
if err != nil {
return fmt.Errorf("скачивание %s: %w", a.Name, err)
}
// DownloadArtifact кладёт файл под именем a.File; если целевое имя
// иное — переименуем атомарно.
if path != dst {
if err := os.Rename(path, dst); err != nil {
return fmt.Errorf("установка %s: %w", a.Name, err)
cleanupStaging(24 * time.Hour)
staging := filepath.Join(updateStagingDir, fmt.Sprintf("%d", time.Now().UnixNano()))
if err := os.MkdirAll(staging, 0o750); err != nil {
return "", fmt.Errorf("каталог подготовки: %w", err)
}
b, _ := json.Marshal(sm)
if err := os.WriteFile(filepath.Join(staging, "manifest.json"), b, 0o640); err != nil {
return "", err
}
var list []string
var req struct {
Names []string `json:"names"`
}
for _, c := range selected {
for _, a := range m.Artifacts {
if a.Name == c.Name {
if _, err := cl.DownloadArtifact(ctx, a, staging); err != nil {
return "", fmt.Errorf("скачивание %s: %w", c.Name, err)
}
}
}
log.Printf("lk-gateway: обновлён %s → %s (%s)", a.Name, dst, m.Version)
updated++
req.Names = append(req.Names, c.Name)
list = append(list, c.Name+" "+c.Available)
}
if updated == 0 {
return fmt.Errorf("в манифесте %s нет обновляемых бинарей", m.Version)
rb, _ := json.Marshal(req)
if err := os.WriteFile(filepath.Join(staging, "request.json"), rb, 0o640); err != nil {
return "", err
}
log.Printf("lk-gateway: обновление компонентов: %s — передано root-установщику", strings.Join(list, ", "))
wctx, cancel := context.WithTimeout(ctx, 5*time.Minute)
defer cancel()
res, err := runKeymediaOp(wctx, "component-install", map[string]string{"staging": staging})
_ = os.RemoveAll(staging)
if err != nil {
return "", fmt.Errorf("установка: %w", err)
}
msg := "установлено: " + strings.Join(list, ", ")
if r, ok := res["restarted"].([]any); ok && len(r) > 0 {
var units []string
for _, x := range r {
units = append(units, fmt.Sprint(x))
}
msg += "; перезапущены службы: " + strings.Join(units, ", ")
}
return msg, nil
}
log.Printf("lk-gateway: обновление до %s применено (%d файлов), перезапуск через systemd…", m.Version, updated)
// Завершаемся с ненулевым кодом — systemd Restart=on-failure поднимет
// новый бинарь. Даём пару секунд на флаш логов/ответа.
go func() {
time.Sleep(800 * time.Millisecond)
os.Exit(42)
}()
return nil
// updateComponentRows — компоненты для раздела «Обновления» без обращения к артефактории:
// установленное на этом сервере и версии из последней проверки.
func updateComponentRows(rc *RuntimeConfig) ([]release.ComponentState, bool) {
rows := release.Plan("", nil, release.LoadInstalled(release.InstalledFile), BuildVersion)
avail := rc.Snapshot().Update.Components
var out []release.ComponentState
any := false
for _, r := range rows {
r.Available = avail[r.Name]
r.Update = r.Available != "" && (r.Present || r.Core) && (r.Installed == "" || release.IsNewer(r.Available, r.Installed))
if r.Present || r.Core || r.Available != "" {
out = append(out, r)
any = any || r.Update
}
}
return out, any
}
func pickVersion(v string) string {
if v == "" {
return "неизвестна"
}
return v
}
// cleanupStaging — удалить брошенные каталоги подготовки (например, если bj перезапустился при обновлении ядра).
func cleanupStaging(olderThan time.Duration) {
entries, err := os.ReadDir(updateStagingDir)
if err != nil {
return
}
for _, e := range entries {
if info, err := e.Info(); err == nil && e.IsDir() && time.Since(info.ModTime()) > olderThan {
_ = os.RemoveAll(filepath.Join(updateStagingDir, e.Name()))
}
}
}
// updateLoop — фоновая авто-проверка обновлений (если включена).
@@ -174,7 +252,7 @@ func (u *Updater) updateLoop(ctx context.Context) {
cctx, cancel := context.WithTimeout(ctx, 90*time.Second)
defer cancel()
if st, err := u.CheckForUpdate(cctx); err == nil && st.HasUpdate {
log.Printf("lk-gateway: доступно обновление %s (текущая %s)", st.Available, st.CurrentVersion)
log.Printf("lk-gateway: %s", st.Message)
}
}
// первая проверка через минуту после старта
@@ -182,6 +260,7 @@ func (u *Updater) updateLoop(ctx context.Context) {
case <-ctx.Done():
return
case <-time.After(time.Minute):
cleanupStaging(time.Hour)
check()
}
for {
@@ -193,12 +272,3 @@ func (u *Updater) updateLoop(ctx context.Context) {
}
}
}
func dirOf(path string) string {
for i := len(path) - 1; i >= 0; i-- {
if path[i] == '/' {
return path[:i]
}
}
return "."
}