From 29086973634738b2583696338a8e9a4807cc1603 Mon Sep 17 00:00:00 2001 From: zuevav Date: Tue, 15 Sep 2026 20:16:36 +0300 Subject: [PATCH] =?UTF-8?q?feat(update):=20=D0=BD=D0=B5=D0=B7=D0=B0=D0=B2?= =?UTF-8?q?=D0=B8=D1=81=D0=B8=D0=BC=D1=8B=D0=B5=20=D0=BE=D0=B1=D0=BD=D0=BE?= =?UTF-8?q?=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=BA=D0=BE=D0=BC=D0=BF?= =?UTF-8?q?=D0=BE=D0=BD=D0=B5=D0=BD=D1=82=D0=BE=D0=B2=20bj?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- cmd/bj-component-install/main.go | 262 ++++++++++++++++++ cmd/bj-release/main.go | 25 +- deploy/linux/sbin/bj-keymedia-worker | 1 + internal/lkgateway/runtimeconfig.go | 2 + internal/lkgateway/setup.go | 32 ++- internal/lkgateway/updater.go | 222 ++++++++++----- .../lkgateway/web/templates/admin_setup.html | 35 ++- internal/release/client.go | 20 +- internal/release/components.go | 161 +++++++++++ internal/release/components_test.go | 66 +++++ internal/release/manifest.go | 8 +- 11 files changed, 720 insertions(+), 114 deletions(-) create mode 100644 cmd/bj-component-install/main.go create mode 100644 internal/release/components.go create mode 100644 internal/release/components_test.go diff --git a/cmd/bj-component-install/main.go b/cmd/bj-component-install/main.go new file mode 100644 index 0000000..e342a38 --- /dev/null +++ b/cmd/bj-component-install/main.go @@ -0,0 +1,262 @@ +// Command bj-component-install — root-установщик компонентов bj (независимые обновления). +// +// bj-server работает без прав root: он скачивает подписанный манифест релиза и выбранные файлы в +// каталог подготовки и просит root-воркер (bj-keymedia-worker, действие component-install) вызвать +// этот инструмент. Установщик ничему из каталога не доверяет: сам проверяет подпись манифеста +// публичным ключом из /etc/bj/update.pub (принадлежит root) и sha256 каждого файла, ставит только +// известные компоненты (release.Components) по их путям, не откатывает на более старую версию, +// сохраняет прежний файл (<путь>.bak-ДАТА, хранятся 5 последних), перезапускает службы (ядро +// bj-server последним) и записывает установленные версии в /var/lib/bj/.bj/components.json. +// Вывод — JSON для bj-server. +// +// bj-component-install <каталог подготовки> +// в каталоге: manifest.json (подписанный), request.json {"names": [...]}, файлы компонентов +// bj-component-install -root /tmp/песочница -pub test.pub -no-restart <каталог> (проверка на копии) +package main + +import ( + "encoding/json" + "errors" + "flag" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" + "syscall" + "time" + + "git.zetit.ru/zuevav/Bridge-and-Join-s/internal/release" +) + +const stagingBase = "/var/lib/bj/update-staging" + +type result struct { + OK bool `json:"ok"` + Error string `json:"error,omitempty"` + Release string `json:"release,omitempty"` + Installed []map[string]string `json:"installed,omitempty"` + Restarted []string `json:"restarted,omitempty"` +} + +func main() { + fs := flag.NewFlagSet("bj-component-install", flag.ExitOnError) + root := fs.String("root", "", "корень установки (для проверки в песочнице; по умолчанию /)") + pubPath := fs.String("pub", "/etc/bj/update.pub", "публичный ключ издателя (base64 Ed25519), файл root") + noRestart := fs.Bool("no-restart", false, "не перезапускать службы") + _ = fs.Parse(os.Args[1:]) + res := run(*root, *pubPath, !*noRestart, fs.Arg(0)) + b, _ := json.Marshal(res) + fmt.Println(string(b)) + if !res.OK { + os.Exit(1) + } +} + +func run(root, pubPath string, restart bool, staging string) (res result) { + fail := func(format string, a ...any) result { + res.OK, res.Error = false, fmt.Sprintf(format, a...) + return res + } + if staging == "" { + return fail("не указан каталог подготовки") + } + staging = filepath.Clean(staging) + if root == "" && !strings.HasPrefix(staging, stagingBase+"/") { + return fail("каталог подготовки должен быть внутри %s", stagingBase) + } + pub, err := release.LoadPublicKey(pubPath) + if err != nil { + return fail("публичный ключ обновлений (%s): %v", pubPath, err) + } + var sm release.SignedManifest + if err := readJSON(staging, "manifest.json", &sm); err != nil { + return fail("манифест: %v", err) + } + m, err := release.Verify(&sm, pub) + if err != nil { + return fail("подпись манифеста не прошла проверку: %v", err) + } + res.Release = m.Version + var req struct { + Names []string `json:"names"` + } + if err := readJSON(staging, "request.json", &req); err != nil { + return fail("запрос: %v", err) + } + if len(req.Names) == 0 { + return fail("не выбраны компоненты") + } + + installedPath := release.InstalledFile + if root != "" { + installedPath = filepath.Join(root, installedPath) + } + installed := release.LoadInstalled(installedPath) + + // Проверки до любой установки: всё или ничего. + type job struct { + c release.Component + a release.Artifact + file string + } + var jobs []job + for _, name := range req.Names { + c, ok := release.ComponentByName(name) + if !ok { + return fail("неизвестный компонент %q", name) + } + var art *release.Artifact + for i := range m.Artifacts { + if m.Artifacts[i].Name == name { + art = &m.Artifacts[i] + } + } + if art == nil { + return fail("компонента %s нет в релизе %s", name, m.Version) + } + ver := art.Version + if ver == "" { + ver = m.Version + } + art.Version = ver + if _, err := os.Stat(c.PathAt(root)); err != nil && !c.Core { + return fail("%s не установлен на этом сервере — модуль ставится установщиком, а не обновлением", name) + } + if prev, ok := installed[name]; ok && prev.Version != "" && release.IsNewer(prev.Version, ver) { + return fail("%s: установлена %s, в релизе более старая %s — откат не выполняется", name, prev.Version, ver) + } + file := filepath.Join(staging, filepath.Base(art.File)) + fi, err := os.Lstat(file) + if err != nil || !fi.Mode().IsRegular() { + return fail("%s: файл %s не найден в каталоге подготовки", name, art.File) + } + if err := release.VerifyArtifact(file, *art); err != nil { + return fail("%s: %v", name, err) + } + jobs = append(jobs, job{c: c, a: *art, file: file}) + } + // Порядок как в release.Components: ядро первым в списке ставится, но перезапускается последним. + sort.SliceStable(jobs, func(i, j int) bool { return order(jobs[i].c.Name) < order(jobs[j].c.Name) }) + + ts := time.Now().Format("20060102-150405") + var units []string + for _, j := range jobs { + dst := j.c.PathAt(root) + if err := install(j.file, dst, j.c.Exec, ts); err != nil { + return fail("установка %s: %v", j.c.Name, err) + } + installed[j.c.Name] = release.Installed{Version: j.a.Version, SHA256: j.a.SHA256, InstalledAt: time.Now().UTC()} + res.Installed = append(res.Installed, map[string]string{"name": j.c.Name, "version": j.a.Version}) + if j.c.Unit != "" { + units = append(units, j.c.Unit) + } + } + if err := release.SaveInstalled(installedPath, installed); err != nil { + return fail("запись установленных версий: %v", err) + } + chownLikeParent(installedPath) + + if restart { + sort.SliceStable(units, func(i, j int) bool { return units[j] == "bj-server" && units[i] != "bj-server" }) + for _, u := range units { + if out, err := exec.Command("systemctl", "restart", u).CombinedOutput(); err != nil { + return fail("перезапуск %s: %v: %s", u, err, strings.TrimSpace(string(out))) + } + res.Restarted = append(res.Restarted, u) + } + } + res.OK = true + return res +} + +func order(name string) int { + for i, c := range release.Components { + if c.Name == name { + return i + } + } + return len(release.Components) +} + +func readJSON(dir, name string, v any) error { + p := filepath.Join(dir, name) + fi, err := os.Lstat(p) + if err != nil { + return err + } + if !fi.Mode().IsRegular() || fi.Size() > 1<<20 { + return errors.New(name + ": не обычный файл или слишком большой") + } + b, err := os.ReadFile(p) + if err != nil { + return err + } + return json.Unmarshal(b, v) +} + +// install — копия прежнего файла, запись нового рядом и атомарная замена. +func install(src, dst string, execBit bool, ts string) error { + if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { + return err + } + if _, err := os.Stat(dst); err == nil { + if err := copyFile(dst, dst+".bak-"+ts, 0o755); err != nil { + return fmt.Errorf("копия прежнего файла: %w", err) + } + pruneBackups(dst, 5) + } + mode := os.FileMode(0o644) + if execBit { + mode = 0o755 + } + tmp := dst + ".new" + if err := copyFile(src, tmp, mode); err != nil { + return err + } + return os.Rename(tmp, dst) +} + +func copyFile(src, dst string, mode os.FileMode) error { + in, err := os.Open(src) + if err != nil { + return err + } + defer in.Close() + out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, mode) + if err != nil { + return err + } + if _, err := io.Copy(out, in); err != nil { + out.Close() + return err + } + if err := out.Sync(); err != nil { + out.Close() + return err + } + if err := out.Close(); err != nil { + return err + } + return os.Chmod(dst, mode) +} + +// pruneBackups — хранить keep последних копий <путь>.bak-ДАТА-ВРЕМЯ. +func pruneBackups(dst string, keep int) { + files, _ := filepath.Glob(dst + ".bak-2*") + sort.Strings(files) + for len(files) > keep { + _ = os.Remove(files[0]) + files = files[1:] + } +} + +func chownLikeParent(path string) { + if fi, err := os.Stat(filepath.Dir(path)); err == nil { + if s, ok := fi.Sys().(*syscall.Stat_t); ok { + _ = os.Chown(path, int(s.Uid), int(s.Gid)) + } + } +} diff --git a/cmd/bj-release/main.go b/cmd/bj-release/main.go index 65f5d36..70f50c8 100644 --- a/cmd/bj-release/main.go +++ b/cmd/bj-release/main.go @@ -48,6 +48,7 @@ func main() { func usage() { fmt.Fprintln(os.Stderr, "bj-release keygen -out ") fmt.Fprintln(os.Stderr, "bj-release build -dir -version -channel -key -keyid -out [-notes ]") + fmt.Fprintln(os.Stderr, " версия отдельного файла — в <файл>.version рядом (иначе версия релиза -version)") os.Exit(2) } @@ -97,11 +98,11 @@ func build(args []string) { // Имена артефактов, которые издаём (логическое имя → ставить +x). known := map[string]bool{ - "bj-server": true, // Go-бинарь - "crypto-service.jar": false, // Java сайдкар - "install-validata.sh": true, - "install.sh": true, - "configure-ish.sql": false, + "bj-server": true, // Go-бинарь + "crypto-service.jar": false, // Java сайдкар + "install-validata.sh": true, + "install.sh": true, + "configure-ish.sql": false, } entries, err := os.ReadDir(*dir) @@ -110,7 +111,7 @@ func build(args []string) { } var arts []release.Artifact for _, e := range entries { - if e.IsDir() || e.Name() == "manifest.json" { + if e.IsDir() || e.Name() == "manifest.json" || strings.HasSuffix(e.Name(), ".version") { continue } full := filepath.Join(*dir, e.Name()) @@ -119,6 +120,14 @@ func build(args []string) { fatal("hash %s: %v", e.Name(), err) } exec, ok := known[e.Name()] + if c, isComp := release.ComponentByName(e.Name()); isComp { + exec, ok = c.Exec, true + } + // Своя версия компонента: <файл>.version рядом с файлом (независимые обновления). + ver := *version + if vb, err := os.ReadFile(full + ".version"); err == nil && strings.TrimSpace(string(vb)) != "" { + ver = strings.TrimSpace(string(vb)) + } if !ok { // неизвестный файл — включаем, +x по расширению exec = strings.HasSuffix(e.Name(), ".sh") @@ -126,7 +135,7 @@ func build(args []string) { arts = append(arts, release.Artifact{ Name: e.Name(), File: e.Name(), - Version: *version, + Version: ver, SHA256: sha, Size: size, Exec: exec, @@ -159,7 +168,7 @@ func build(args []string) { fmt.Printf("Манифест %s: версия %s, канал %s, артефактов %d, подписан ключом %s\n", *out, *version, *channel, len(arts), *keyID) for _, a := range arts { - fmt.Printf(" %-22s %10d B %s\n", a.Name, a.Size, a.SHA256[:16]) + fmt.Printf(" %-22s %-10s %10d B %s\n", a.Name, a.Version, a.Size, a.SHA256[:16]) } } diff --git a/deploy/linux/sbin/bj-keymedia-worker b/deploy/linux/sbin/bj-keymedia-worker index 003e9ee..90a7744 100755 --- a/deploy/linux/sbin/bj-keymedia-worker +++ b/deploy/linux/sbin/bj-keymedia-worker @@ -10,6 +10,7 @@ process_one(){ rm -f "$req" case "$action" in quikexport-*) out=$(BJ_EXTRA="$extra" /usr/local/sbin/bj-quikexport-admin "${action#quikexport-}" "$staging" 2>&1) ;; + component-install) out=$(/usr/local/sbin/bj-component-install "$staging" 2>&1) ;; *) out=$(BJ_TARGET_DEV="$tdev" BJ_TARGET_MNT="$tmnt" BJ_EXTRA="$extra" /usr/local/sbin/bj-keymedia-install "$action" "$staging" "$profile" 2>&1) ;; esac printf '%s' "$out" > "$res.tmp"; chown bj:bj "$res.tmp" 2>/dev/null diff --git a/internal/lkgateway/runtimeconfig.go b/internal/lkgateway/runtimeconfig.go index 00caad7..d3fc31d 100644 --- a/internal/lkgateway/runtimeconfig.go +++ b/internal/lkgateway/runtimeconfig.go @@ -92,6 +92,8 @@ type UpdateSettings struct { LastResult string `json:"last_result"` // текст результата проверки Available string `json:"available_version"` // доступная версия (если новее) Notes string `json:"notes,omitempty"` // заметки доступного релиза + // Components — версии компонентов в последнем проверенном релизе (имя → версия). + Components map[string]string `json:"components,omitempty"` } // NewsSettings — лента новостей (события системы, окна техработ, обновления diff --git a/internal/lkgateway/setup.go b/internal/lkgateway/setup.go index 8de87a7..4106d07 100644 --- a/internal/lkgateway/setup.go +++ b/internal/lkgateway/setup.go @@ -6,6 +6,7 @@ import ( "encoding/json" "errors" "fmt" + "git.zetit.ru/zuevav/Bridge-and-Join-s/internal/release" "io" "log" "net/http" @@ -291,7 +292,9 @@ type SetupData struct { ReadyCount int TotalCount int Certificates []cryptocli.Certificate - CurrentVersion string // версия bj-server (для раздела «Обновления») + CurrentVersion string // версия bj-server (для раздела «Обновления») + Components []release.ComponentState // компоненты и их версии (раздел «Обновления») + UpdatesAvailable bool License LicenseStatus // статус лицензии Health []HealthStatus // здоровье фоновых компонентов AdvancedUnlocked bool // открыты ли перспективные модули (QUIK, Fancy) @@ -338,8 +341,11 @@ func (h *setupHandlers) renderSetup(w http.ResponseWriter, req *http.Request, fl ready++ } } + comps, anyUpdate := updateComponentRows(h.rc) data := SetupData{ page: nowPage("Настройка", "setup"), + Components: comps, + UpdatesAvailable: anyUpdate, Settings: s, Readiness: r, ReadyCount: ready, @@ -1225,11 +1231,27 @@ func (h *setupHandlers) applyUpdate(w http.ResponseWriter, r *http.Request) { http.Error(w, "method", http.StatusMethodNotAllowed) return } - if err := NewUpdater(h.rc).ApplyUpdate(r.Context()); err != nil { - setupFlash(w, r, "Обновление не применено: "+err.Error()) - return + _ = r.ParseForm() + names := r.Form["component"] + rc := h.rc + // В фоне: при обновлении ядра bj-server перезапустится раньше, чем придёт ответ установщика. + go func() { + msg, err := NewUpdater(rc).ApplyUpdate(context.Background(), names) + cfg := rc.Snapshot().Update + if err != nil { + msg = "Обновление не применено: " + err.Error() + } else { + msg = "Обновление: " + msg + } + log.Printf("lk-gateway: %s", msg) + cfg.LastResult, cfg.LastCheck = msg, time.Now().UTC() + _ = rc.SaveUpdateSettings(cfg) + }() + what := "всех компонентов с новой версией" + if len(names) > 0 { + what = strings.Join(names, ", ") } - setupFlash(w, r, "Обновление скачано и применено — bj-server перезапускается, обновите страницу через 10 секунд") + setupFlash(w, r, "Обновление "+what+" запущено: файлы скачиваются и проверяются, затем перезапускаются службы. Итог — в строке «Последняя проверка» через минуту; при обновлении ядра страница временно не отвечает.") } // runTestClaim делает CreateClaim + ждёт финального состояния через GetClaim. diff --git a/internal/lkgateway/updater.go b/internal/lkgateway/updater.go index ffc0d3b..7b2809b 100644 --- a/internal/lkgateway/updater.go +++ b/internal/lkgateway/updater.go @@ -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 "." -} diff --git a/internal/lkgateway/web/templates/admin_setup.html b/internal/lkgateway/web/templates/admin_setup.html index af2a722..72d044a 100644 --- a/internal/lkgateway/web/templates/admin_setup.html +++ b/internal/lkgateway/web/templates/admin_setup.html @@ -922,21 +922,26 @@

Обновления

-

Версия bj-server

-
- {{.CurrentVersion}} - {{if .Settings.Update.Available}} - {{if ne .Settings.Update.Available .CurrentVersion}} - ● Доступна {{.Settings.Update.Available}} -
- -
- {{else}}● Актуальная версия{{end}} - {{end}} -
+

Компоненты

+

Каждый компонент обновляется отдельно: своя версия в релизе, перезапускается только его служба. Файлы ставит root-установщик после повторной проверки подписи и контрольных сумм. Модули, которых нет на этом сервере, обновлением не ставятся.

+
+ + + {{range .Components}} + + + + + + {{end}} + +
КомпонентУстановленаДоступнаСлужба
{{.Title}}
{{.Name}}
{{if .Present}}{{if .Installed}}{{.Installed}}{{else}}неизвестна{{end}}{{else}}не установлен{{end}}{{if .Available}}{{.Available}}{{if .Update}} новее{{end}}{{else}}нет данных{{end}}{{.Unit}}{{if .Update}}
{{end}}
+
+ {{if .UpdatesAvailable}}
{{end}} +
- {{if .Settings.Update.Notes}}

Что нового: {{.Settings.Update.Notes}}

{{end}} - {{if not .Settings.Update.LastCheck.IsZero}}

Последняя проверка: {{.Settings.Update.LastCheck.Format "02.01.2006 15:04"}} — {{.Settings.Update.LastResult}}

{{end}} + {{if .Settings.Update.Notes}}

Что нового в релизе {{.Settings.Update.Available}}: {{.Settings.Update.Notes}}

{{end}} + {{if not .Settings.Update.LastCheck.IsZero}}

Последняя проверка: {{.Settings.Update.LastCheck.Format "02.01.2006 15:04"}}. {{.Settings.Update.LastResult}}

{{end}} {{if and .License.Present (not .License.AllowsUpdates)}}

Текущий план «{{.License.Plan}}» не включает обновления.

{{end}}
@@ -946,7 +951,7 @@
- +