Files
Bridge-and-Join-s/internal/lkgateway/updater.go
T
zuevav 17d8ec3988 Обновления из файла для закрытого контура: пакет с портала → проверка подписи → ядро, потом модули
- release: пакет обновления (ExtractBundle/ReadStagedManifest/WriteBundle), Artifact.MinCore,
  план блокирует модуль, которому нужно ядро новее, чем будет после установки
- bj-release: модули получают min_core = версия релиза
- настройки → Обновления: «Обновить из файла» (zip до 2 ГБ), фон как у сетевого обновления,
  причина блокировки в таблице компонентов

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q4YVo4UMopqT5DzicqpPjL
2026-09-18 11:27:23 +03:00

463 lines
17 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
}
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)
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)
avail := rc.Snapshot().Update.Components
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) })
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
}