fdd70c06dd
Правки установки приезжают обновлением с сервера лицензий, образ ISO пересобирать не нужно. Прежний установщик обновлений bj-installer не знает — он откладывается и ставится после перезапуска bj-server (ensureSplitModules). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014FsEKyJrYF69Cj9ZXzFLRi
530 lines
20 KiB
Go
530 lines
20 KiB
Go
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 || c.Name == "bj-installer" { // bj-installer прежний установщик обновлений не знает
|
||
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
|
||
installed := release.LoadInstalled(release.InstalledFile)
|
||
for _, c := range release.Plan("", nil, installed, BuildVersion) {
|
||
if c.FromCore && !c.Present && u.allowed(c.Feature) {
|
||
names = append(names, c.Name)
|
||
}
|
||
}
|
||
// bj-installer обновлением ещё не ставился (сервер с образа или обновлён прежним установщиком
|
||
// обновлений): ставим сразу — с ним приезжают свежие службы и скрипты. Только если на сервере
|
||
// обновлений он новее, иначе ApplyUpdate вернёт «обновление не требуется» — это не ошибка.
|
||
if _, ok := installed["bj-installer"]; !ok && licenseStatus(u.rc).AllowsUpdates {
|
||
if st, err := u.CheckForUpdate(ctx); err == nil {
|
||
for _, c := range st.Components {
|
||
if c.Name == "bj-installer" && c.Update {
|
||
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, "недоступ")
|
||
}
|