Files
Bridge-and-Join-s/internal/lkgateway/updater.go
T
zuevav 2908697363 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>
2026-09-15 20:16:36 +03:00

275 lines
9.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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"
)
// BuildVersion — версия bj-server. Переопределяется при сборке:
//
// go build -ldflags "-X .../lkgateway.BuildVersion=1.2.0"
var BuildVersion = "0.1.0"
// DefaultUpdatePublicKey — публичный ключ артефактории, зашитый в релиз.
// Пустой в исходниках; подставляется при официальной сборке. Если задан в
// настройках (UpdateSettings.PublicKey) — приоритет у настроек (для проверки
// в bj; root-установщик верит только /etc/bj/update.pub).
var DefaultUpdatePublicKey = ""
const updateStagingDir = "/var/lib/bj/update-staging"
type Updater struct{ rc *RuntimeConfig }
func NewUpdater(rc *RuntimeConfig) *Updater { return &Updater{rc: rc} }
type UpdateStatus struct {
Configured bool
CurrentVersion string
Available string // версия релиза
HasUpdate bool // есть что обновить хотя бы в одном компоненте
Channel string
Notes string
Message string
Components []release.ComponentState
}
func (u *Updater) updateClient() (*release.Client, error) {
cfg := u.rc.Snapshot().Update
pub := cfg.PublicKey
if pub == "" {
pub = DefaultUpdatePublicKey
}
if cfg.BaseURL == "" || pub == "" {
return nil, fmt.Errorf("обновления не настроены (нужны URL артефактории и публичный ключ)")
}
channel := cfg.Channel
if channel == "" {
channel = "stable"
}
return release.NewClient(cfg.BaseURL, channel, pub)
}
// CheckForUpdate — манифест и состояние каждого компонента.
func (u *Updater) CheckForUpdate(ctx context.Context) (UpdateStatus, error) {
st := UpdateStatus{CurrentVersion: BuildVersion, Channel: u.rc.Snapshot().Update.Channel}
cl, err := u.updateClient()
if err != nil {
st.Message = err.Error()
return st, nil // не настроено — не ошибка
}
st.Configured = true
m, err := cl.FetchManifest(ctx)
if err != nil {
st.Message = "проверка не удалась: " + err.Error()
u.saveCheckResult(st, nil)
return st, err
}
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)
}
}
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, 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 — скачать выбранные компоненты (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)
}
if !ls.AllowsUpdates {
return "", fmt.Errorf("обновления не входят в план %q", ls.Plan)
}
}
cl, err := u.updateClient()
if err != nil {
return "", err
}
sm, m, err := cl.FetchSigned(ctx)
if err != nil {
return "", fmt.Errorf("манифест: %w", err)
}
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)
}
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)
}
}
}
req.Names = append(req.Names, c.Name)
list = append(list, c.Name+" "+c.Available)
}
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
}
// 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 — фоновая авто-проверка обновлений (если включена).
func (u *Updater) updateLoop(ctx context.Context) {
ticker := time.NewTicker(6 * time.Hour)
defer ticker.Stop()
check := func() {
if !u.rc.Snapshot().Update.AutoCheck {
return
}
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", st.Message)
}
}
// первая проверка через минуту после старта
select {
case <-ctx.Done():
return
case <-time.After(time.Minute):
cleanupStaging(time.Hour)
check()
}
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
check()
}
}
}