Files
Bridge-and-Join-s/internal/lkgateway/updater.go
T
zuevav 560c39da18 Обновления: кнопка «Обновить» показывает настоящий результат, устаревшие данные не предлагаются
Жалоба со стенда: в «Обновлениях» висит ЛК 0.2.0, нажимаешь «Обновить» —
ничего не происходит. На деле обновление уходило в фон и падало («манифест
HTTP 404: релизов в канале нет» — стенд на канале stable, релизы выкладываются
в beta), а отказ оседал в незаметной строке «Последняя проверка».

- Модули обновляются на месте и во flash показывается фактический результат;
  в фон уходит только обновление ядра, где ответа установщика всё равно не
  дождаться (bj-server перезапускается раньше).
- Строка результата при отказе показывается предупреждением, а не серым текстом.
- Если последняя проверка не удалась, кнопки установки скрыты, у версии —
  пояснение «нажмите „Проверить обновления“»: предлагать установку по данным
  неудачной проверки нельзя.
- Версии запоминаются вместе с каналом (components_channel); при смене канала
  прошлые данные забываются — они относились к другому каналу.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q4YVo4UMopqT5DzicqpPjL
2026-09-18 19:39:09 +03:00

517 lines
20 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"
"errors"
"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 = release.DefaultPublicKey
const updateStagingDir = "/var/lib/bj/update-staging"
// DefaultUpdateURL — сервер обновлений по умолчанию (встроен в сервер лицензий).
const DefaultUpdateURL = "https://lic.zetit.ru/updates"
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
}
base := cfg.BaseURL
if base == "" {
base = DefaultUpdateURL
}
if pub == "" {
return nil, fmt.Errorf("обновления не настроены: в этой сборке нет открытого ключа подписи релизов")
}
channel := cfg.Channel
if channel == "" {
channel = "stable"
}
cl, err := release.NewClient(base, channel, pub)
if err != nil {
return nil, err
}
cl.Token = strings.TrimSpace(u.rc.Snapshot().License.Key) // лицензионный токен установки
return cl, nil
}
// 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)
release.MarkSplitModules(st.Components, u.allowed)
var upd []string
avail := map[string]string{}
for _, c := range st.Components {
if c.Available != "" {
avail[c.Name] = c.Available
}
if c.New {
upd = append(upd, c.Name+" "+c.Available+" (установка модуля)")
} else 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
}
// updateChannel — канал обновлений установки (пусто = stable).
func updateChannel(s UpdateSettings) string {
if c := strings.TrimSpace(s.Channel); c != "" {
return c
}
return "stable"
}
func (u *Updater) channel() string { return updateChannel(u.rc.Snapshot().Update) }
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
cfg.ComponentsChannel = u.channel()
}
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)
release.MarkSplitModules(plan, u.allowed)
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)
}
// Прежний root-установщик не умеет впервые ставить выделенные из ядра модули: пока он сам не
// обновлён, такие модули откладываются — их поставит ensureSplitModules после перезапуска.
installerOutdated := false
for _, c := range plan {
if c.Name == "bj-component-install" && c.Update {
installerOutdated = true
}
}
var deferred []string
if installerOutdated {
kept := selected[:0]
for _, c := range selected {
if c.New {
deferred = append(deferred, c.Name)
continue
}
kept = append(kept, c)
}
selected = kept
if len(selected) == 0 {
return "", fmt.Errorf("сначала обновите установщик обновлений (bj-component-install): текущий не ставит модули %s", strings.Join(deferred, ", "))
}
}
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"`
New []string `json:"new,omitempty"`
}
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)
if c.New {
req.New = append(req.New, 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, ", ")
}
if len(deferred) > 0 {
msg += "; модули " + strings.Join(deferred, ", ") + " будут установлены после перезапуска bj-server новым установщиком"
}
return msg, nil
}
// allowed — разрешён ли лицензией признак компонента.
func (u *Updater) allowed(feature string) bool { return moduleAllowed(u.rc, feature) }
// ensureSplitModules — выделенные из ядра модули, которые входят в лицензию, но ещё не стоят на этом
// сервере (например, ядро обновилось прежним установщиком), ставятся сами: без своих служб разделы
// этих модулей в bj не работают. Раз за запуск bj-server; ошибки — только в журнал и «Последнюю проверку».
func (u *Updater) ensureSplitModules(ctx context.Context) {
var names []string
for _, c := range release.Plan("", nil, release.LoadInstalled(release.InstalledFile), BuildVersion) {
if c.FromCore && !c.Present && u.allowed(c.Feature) {
names = append(names, c.Name)
}
}
if len(names) == 0 {
return
}
cctx, cancel := context.WithTimeout(ctx, 10*time.Minute)
defer cancel()
msg, err := u.ApplyUpdate(cctx, names)
if err != nil {
msg = "Установка модулей " + strings.Join(names, ", ") + " не выполнена: " + err.Error()
} else {
msg = "Установка модулей: " + msg
}
log.Printf("lk-gateway: %s", msg)
cfg := u.rc.Snapshot().Update
cfg.LastResult, cfg.LastCheck = msg, time.Now().UTC()
_ = u.rc.SaveUpdateSettings(cfg)
}
// updateComponentRows — компоненты для раздела «Обновления» без обращения к артефактории:
// установленное на этом сервере и версии из последней проверки.
func updateComponentRows(rc *RuntimeConfig) ([]release.ComponentState, bool) {
rows := release.Plan("", nil, release.LoadInstalled(release.InstalledFile), BuildVersion)
set := rc.Snapshot().Update
avail := set.Components
// Версии из проверки в другом канале к текущему каналу не относятся: по ним
// «Обновить» уходило бы в пустоту («релизов в канале нет»).
if set.ComponentsChannel != "" && set.ComponentsChannel != updateChannel(set) {
avail = nil
}
var out []release.ComponentState
any := false
vers := systemVersions(rc)
for _, r := range rows {
if r.Present && r.Installed == "" {
r.Installed = vers.Get(r.Name).Version // выложен вручную — версия со слов самого компонента
}
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)
}
}
release.MarkSplitModules(out, func(f string) bool { return moduleAllowed(rc, f) })
// Последняя проверка не удалась — предлагать установку по старым данным нельзя:
// кнопка ушла бы в тот же отказ. Показываем причину и просим проверить заново.
if updateResultFailed(set.LastResult) {
for i := range out {
if out[i].Update && out[i].Available != "" {
out[i].Blocked = "проверка обновлений не удалась — нажмите «Проверить обновления»"
}
out[i].Update = false
}
}
for _, r := range out {
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)
u.ensureSplitModules(ctx)
check()
}
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
check()
}
}
}
// ApplyBundle — обновление из файла для закрытого контура: пакет с портала клиента
// (подписанный манифест + файлы компонентов). Что ставить, решает тот же план, что и при
// обновлении по сети: всё, что новее установленного, в порядке компонентов — ядро первым,
// модули за ним. Модуль, которому нужно более новое ядро, чем будет после установки,
// пропускается с объяснением.
func (u *Updater) ApplyBundle(ctx context.Context, zipPath string) (string, error) {
defer os.Remove(zipPath)
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)
}
}
pubB64 := u.rc.Snapshot().Update.PublicKey
if pubB64 == "" {
pubB64 = DefaultUpdatePublicKey
}
pub, err := release.ParsePublicKey(pubB64)
if err != nil {
return "", fmt.Errorf("открытый ключ подписи релизов: %w", err)
}
cleanupStaging(24 * time.Hour)
staging := filepath.Join(updateStagingDir, fmt.Sprintf("%d", time.Now().UnixNano()))
files, err := release.ExtractBundle(zipPath, staging)
if err != nil {
_ = os.RemoveAll(staging)
return "", err
}
sm, m, err := release.ReadStagedManifest(staging, pub)
if err != nil {
_ = os.RemoveAll(staging)
return "", err
}
_ = sm
have := map[string]bool{}
for _, f := range files {
have[f] = true
}
plan := release.Plan("", m, release.LoadInstalled(release.InstalledFile), BuildVersion)
release.MarkSplitModules(plan, u.allowed)
var req struct {
Names []string `json:"names"`
New []string `json:"new,omitempty"`
}
var list, skipped []string
for _, c := range plan {
if c.Blocked != "" {
skipped = append(skipped, c.Title+": "+c.Blocked)
continue
}
if !c.Update {
continue
}
if !have[c.Name] { // в пакете может не быть файла модуля (не входит в лицензию клиента)
continue
}
if !u.allowed(c.Feature) && c.Feature != "updates" {
skipped = append(skipped, c.Title+": не входит в лицензию")
continue
}
req.Names = append(req.Names, c.Name)
if c.New {
req.New = append(req.New, c.Name)
}
list = append(list, c.Name+" "+c.Available)
}
if len(req.Names) == 0 {
_ = os.RemoveAll(staging)
msg := fmt.Sprintf("в пакете релиз %s: нечего обновлять, все компоненты актуальны", m.Version)
if len(skipped) > 0 {
msg += "; пропущено: " + strings.Join(skipped, "; ")
}
return "", errors.New(msg)
}
rb, _ := json.Marshal(req)
if err := os.WriteFile(filepath.Join(staging, "request.json"), rb, 0o640); err != nil {
return "", err
}
log.Printf("lk-gateway: обновление из файла (релиз %s): %s — передано root-установщику", m.Version, strings.Join(list, ", "))
wctx, cancel := context.WithTimeout(ctx, 10*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 := fmt.Sprintf("из файла (релиз %s) установлено: %s", m.Version, 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, ", ")
}
if len(skipped) > 0 {
msg += "; пропущено: " + strings.Join(skipped, "; ")
}
return msg, nil
}
// updateTouchesCore — затронет ли обновление ядро (тогда ответа установщика не дождаться).
func updateTouchesCore(rc *RuntimeConfig, names []string) bool {
if len(names) == 0 {
// «Обновить всё»: ядро попадёт в набор, если для него есть новая версия
rows, _ := updateComponentRows(rc)
for _, r := range rows {
if r.Core && r.Update {
return true
}
}
return false
}
for _, n := range names {
if c, ok := release.ComponentByName(n); ok && c.Core {
return true
}
}
return false
}
// updateResultFailed — последняя строка результата говорит об отказе.
func updateResultFailed(result string) bool {
r := strings.ToLower(result)
return strings.Contains(r, "не применено") || strings.Contains(r, "ошибка") ||
strings.Contains(r, "не удалось") || strings.Contains(r, "недоступ")
}