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>
This commit is contained in:
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+17
-8
@@ -48,6 +48,7 @@ func main() {
|
|||||||
func usage() {
|
func usage() {
|
||||||
fmt.Fprintln(os.Stderr, "bj-release keygen -out <prefix>")
|
fmt.Fprintln(os.Stderr, "bj-release keygen -out <prefix>")
|
||||||
fmt.Fprintln(os.Stderr, "bj-release build -dir <artifacts> -version <v> -channel <c> -key <priv> -keyid <id> -out <manifest.json> [-notes <txt>]")
|
fmt.Fprintln(os.Stderr, "bj-release build -dir <artifacts> -version <v> -channel <c> -key <priv> -keyid <id> -out <manifest.json> [-notes <txt>]")
|
||||||
|
fmt.Fprintln(os.Stderr, " версия отдельного файла — в <файл>.version рядом (иначе версия релиза -version)")
|
||||||
os.Exit(2)
|
os.Exit(2)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,11 +98,11 @@ func build(args []string) {
|
|||||||
|
|
||||||
// Имена артефактов, которые издаём (логическое имя → ставить +x).
|
// Имена артефактов, которые издаём (логическое имя → ставить +x).
|
||||||
known := map[string]bool{
|
known := map[string]bool{
|
||||||
"bj-server": true, // Go-бинарь
|
"bj-server": true, // Go-бинарь
|
||||||
"crypto-service.jar": false, // Java сайдкар
|
"crypto-service.jar": false, // Java сайдкар
|
||||||
"install-validata.sh": true,
|
"install-validata.sh": true,
|
||||||
"install.sh": true,
|
"install.sh": true,
|
||||||
"configure-ish.sql": false,
|
"configure-ish.sql": false,
|
||||||
}
|
}
|
||||||
|
|
||||||
entries, err := os.ReadDir(*dir)
|
entries, err := os.ReadDir(*dir)
|
||||||
@@ -110,7 +111,7 @@ func build(args []string) {
|
|||||||
}
|
}
|
||||||
var arts []release.Artifact
|
var arts []release.Artifact
|
||||||
for _, e := range entries {
|
for _, e := range entries {
|
||||||
if e.IsDir() || e.Name() == "manifest.json" {
|
if e.IsDir() || e.Name() == "manifest.json" || strings.HasSuffix(e.Name(), ".version") {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
full := filepath.Join(*dir, e.Name())
|
full := filepath.Join(*dir, e.Name())
|
||||||
@@ -119,6 +120,14 @@ func build(args []string) {
|
|||||||
fatal("hash %s: %v", e.Name(), err)
|
fatal("hash %s: %v", e.Name(), err)
|
||||||
}
|
}
|
||||||
exec, ok := known[e.Name()]
|
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 {
|
if !ok {
|
||||||
// неизвестный файл — включаем, +x по расширению
|
// неизвестный файл — включаем, +x по расширению
|
||||||
exec = strings.HasSuffix(e.Name(), ".sh")
|
exec = strings.HasSuffix(e.Name(), ".sh")
|
||||||
@@ -126,7 +135,7 @@ func build(args []string) {
|
|||||||
arts = append(arts, release.Artifact{
|
arts = append(arts, release.Artifact{
|
||||||
Name: e.Name(),
|
Name: e.Name(),
|
||||||
File: e.Name(),
|
File: e.Name(),
|
||||||
Version: *version,
|
Version: ver,
|
||||||
SHA256: sha,
|
SHA256: sha,
|
||||||
Size: size,
|
Size: size,
|
||||||
Exec: exec,
|
Exec: exec,
|
||||||
@@ -159,7 +168,7 @@ func build(args []string) {
|
|||||||
fmt.Printf("Манифест %s: версия %s, канал %s, артефактов %d, подписан ключом %s\n",
|
fmt.Printf("Манифест %s: версия %s, канал %s, артефактов %d, подписан ключом %s\n",
|
||||||
*out, *version, *channel, len(arts), *keyID)
|
*out, *version, *channel, len(arts), *keyID)
|
||||||
for _, a := range arts {
|
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])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ process_one(){
|
|||||||
rm -f "$req"
|
rm -f "$req"
|
||||||
case "$action" in
|
case "$action" in
|
||||||
quikexport-*) out=$(BJ_EXTRA="$extra" /usr/local/sbin/bj-quikexport-admin "${action#quikexport-}" "$staging" 2>&1) ;;
|
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) ;;
|
*) out=$(BJ_TARGET_DEV="$tdev" BJ_TARGET_MNT="$tmnt" BJ_EXTRA="$extra" /usr/local/sbin/bj-keymedia-install "$action" "$staging" "$profile" 2>&1) ;;
|
||||||
esac
|
esac
|
||||||
printf '%s' "$out" > "$res.tmp"; chown bj:bj "$res.tmp" 2>/dev/null
|
printf '%s' "$out" > "$res.tmp"; chown bj:bj "$res.tmp" 2>/dev/null
|
||||||
|
|||||||
@@ -92,6 +92,8 @@ type UpdateSettings struct {
|
|||||||
LastResult string `json:"last_result"` // текст результата проверки
|
LastResult string `json:"last_result"` // текст результата проверки
|
||||||
Available string `json:"available_version"` // доступная версия (если новее)
|
Available string `json:"available_version"` // доступная версия (если новее)
|
||||||
Notes string `json:"notes,omitempty"` // заметки доступного релиза
|
Notes string `json:"notes,omitempty"` // заметки доступного релиза
|
||||||
|
// Components — версии компонентов в последнем проверенном релизе (имя → версия).
|
||||||
|
Components map[string]string `json:"components,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewsSettings — лента новостей (события системы, окна техработ, обновления
|
// NewsSettings — лента новостей (события системы, окна техработ, обновления
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/release"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -291,7 +292,9 @@ type SetupData struct {
|
|||||||
ReadyCount int
|
ReadyCount int
|
||||||
TotalCount int
|
TotalCount int
|
||||||
Certificates []cryptocli.Certificate
|
Certificates []cryptocli.Certificate
|
||||||
CurrentVersion string // версия bj-server (для раздела «Обновления»)
|
CurrentVersion string // версия bj-server (для раздела «Обновления»)
|
||||||
|
Components []release.ComponentState // компоненты и их версии (раздел «Обновления»)
|
||||||
|
UpdatesAvailable bool
|
||||||
License LicenseStatus // статус лицензии
|
License LicenseStatus // статус лицензии
|
||||||
Health []HealthStatus // здоровье фоновых компонентов
|
Health []HealthStatus // здоровье фоновых компонентов
|
||||||
AdvancedUnlocked bool // открыты ли перспективные модули (QUIK, Fancy)
|
AdvancedUnlocked bool // открыты ли перспективные модули (QUIK, Fancy)
|
||||||
@@ -338,8 +341,11 @@ func (h *setupHandlers) renderSetup(w http.ResponseWriter, req *http.Request, fl
|
|||||||
ready++
|
ready++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
comps, anyUpdate := updateComponentRows(h.rc)
|
||||||
data := SetupData{
|
data := SetupData{
|
||||||
page: nowPage("Настройка", "setup"),
|
page: nowPage("Настройка", "setup"),
|
||||||
|
Components: comps,
|
||||||
|
UpdatesAvailable: anyUpdate,
|
||||||
Settings: s,
|
Settings: s,
|
||||||
Readiness: r,
|
Readiness: r,
|
||||||
ReadyCount: ready,
|
ReadyCount: ready,
|
||||||
@@ -1225,11 +1231,27 @@ func (h *setupHandlers) applyUpdate(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Error(w, "method", http.StatusMethodNotAllowed)
|
http.Error(w, "method", http.StatusMethodNotAllowed)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := NewUpdater(h.rc).ApplyUpdate(r.Context()); err != nil {
|
_ = r.ParseForm()
|
||||||
setupFlash(w, r, "Обновление не применено: "+err.Error())
|
names := r.Form["component"]
|
||||||
return
|
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.
|
// runTestClaim делает CreateClaim + ждёт финального состояния через GetClaim.
|
||||||
|
|||||||
+146
-76
@@ -1,10 +1,21 @@
|
|||||||
package lkgateway
|
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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/release"
|
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/release"
|
||||||
@@ -17,32 +28,25 @@ var BuildVersion = "0.1.0"
|
|||||||
|
|
||||||
// DefaultUpdatePublicKey — публичный ключ артефактории, зашитый в релиз.
|
// DefaultUpdatePublicKey — публичный ключ артефактории, зашитый в релиз.
|
||||||
// Пустой в исходниках; подставляется при официальной сборке. Если задан в
|
// Пустой в исходниках; подставляется при официальной сборке. Если задан в
|
||||||
// настройках (UpdateSettings.PublicKey) — приоритет у настроек.
|
// настройках (UpdateSettings.PublicKey) — приоритет у настроек (для проверки
|
||||||
|
// в bj; root-установщик верит только /etc/bj/update.pub).
|
||||||
var DefaultUpdatePublicKey = ""
|
var DefaultUpdatePublicKey = ""
|
||||||
|
|
||||||
// installPaths — куда устанавливать артефакты по логическому имени.
|
const updateStagingDir = "/var/lib/bj/update-staging"
|
||||||
// Файлы не из этого списка при авто-обновлении пропускаются (скрипты/SQL
|
|
||||||
// обновляются отдельно, не на лету).
|
|
||||||
var installPaths = map[string]string{
|
|
||||||
"bj-server": "/opt/bj/bj-server",
|
|
||||||
"crypto-service.jar": "/opt/bj/crypto-service.jar",
|
|
||||||
}
|
|
||||||
|
|
||||||
// Updater — авто-обновление bj-server из артефактории (работает поверх rc).
|
|
||||||
type Updater struct{ rc *RuntimeConfig }
|
type Updater struct{ rc *RuntimeConfig }
|
||||||
|
|
||||||
// NewUpdater создаёт Updater на текущем runtime-конфиге.
|
|
||||||
func NewUpdater(rc *RuntimeConfig) *Updater { return &Updater{rc: rc} }
|
func NewUpdater(rc *RuntimeConfig) *Updater { return &Updater{rc: rc} }
|
||||||
|
|
||||||
// UpdateStatus — сводка для UI/handler.
|
|
||||||
type UpdateStatus struct {
|
type UpdateStatus struct {
|
||||||
Configured bool
|
Configured bool
|
||||||
CurrentVersion string
|
CurrentVersion string
|
||||||
Available string
|
Available string // версия релиза
|
||||||
HasUpdate bool
|
HasUpdate bool // есть что обновить хотя бы в одном компоненте
|
||||||
Channel string
|
Channel string
|
||||||
Notes string
|
Notes string
|
||||||
Message string
|
Message string
|
||||||
|
Components []release.ComponentState
|
||||||
}
|
}
|
||||||
|
|
||||||
func (u *Updater) updateClient() (*release.Client, error) {
|
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)
|
return release.NewClient(cfg.BaseURL, channel, pub)
|
||||||
}
|
}
|
||||||
|
|
||||||
// CheckForUpdate скачивает манифест, проверяет подпись, сравнивает версии и
|
// CheckForUpdate — манифест и состояние каждого компонента.
|
||||||
// сохраняет результат в настройки. Возвращает сводку.
|
|
||||||
func (u *Updater) CheckForUpdate(ctx context.Context) (UpdateStatus, error) {
|
func (u *Updater) CheckForUpdate(ctx context.Context) (UpdateStatus, error) {
|
||||||
st := UpdateStatus{CurrentVersion: BuildVersion, Channel: u.rc.Snapshot().Update.Channel}
|
st := UpdateStatus{CurrentVersion: BuildVersion, Channel: u.rc.Snapshot().Update.Channel}
|
||||||
cl, err := u.updateClient()
|
cl, err := u.updateClient()
|
||||||
@@ -75,92 +78,167 @@ func (u *Updater) CheckForUpdate(ctx context.Context) (UpdateStatus, error) {
|
|||||||
m, err := cl.FetchManifest(ctx)
|
m, err := cl.FetchManifest(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
st.Message = "проверка не удалась: " + err.Error()
|
st.Message = "проверка не удалась: " + err.Error()
|
||||||
u.saveCheckResult(st)
|
u.saveCheckResult(st, nil)
|
||||||
return st, err
|
return st, err
|
||||||
}
|
}
|
||||||
st.Available = m.Version
|
st.Available, st.Notes = m.Version, m.Notes
|
||||||
st.Notes = m.Notes
|
st.Components = release.Plan("", m, release.LoadInstalled(release.InstalledFile), BuildVersion)
|
||||||
st.HasUpdate = release.IsNewer(m.Version, BuildVersion)
|
var upd []string
|
||||||
if st.HasUpdate {
|
avail := map[string]string{}
|
||||||
st.Message = fmt.Sprintf("доступна версия %s (текущая %s)", m.Version, BuildVersion)
|
for _, c := range st.Components {
|
||||||
} else {
|
if c.Available != "" {
|
||||||
st.Message = "установлена актуальная версия " + BuildVersion
|
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
|
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 := u.rc.Snapshot().Update
|
||||||
cfg.LastCheck = time.Now().UTC()
|
cfg.LastCheck = time.Now().UTC()
|
||||||
cfg.LastResult = st.Message
|
cfg.LastResult = st.Message
|
||||||
cfg.Available = st.Available
|
cfg.Available = st.Available
|
||||||
cfg.Notes = st.Notes
|
cfg.Notes = st.Notes
|
||||||
|
if avail != nil {
|
||||||
|
cfg.Components = avail
|
||||||
|
}
|
||||||
if err := u.rc.SaveUpdateSettings(cfg); err != nil {
|
if err := u.rc.SaveUpdateSettings(cfg); err != nil {
|
||||||
log.Printf("lk-gateway: сохранение результата проверки обновления: %v", err)
|
log.Printf("lk-gateway: сохранение результата проверки обновления: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ApplyUpdate скачивает обновлённые артефакты (с проверкой подписи манифеста
|
// ApplyUpdate — скачать выбранные компоненты (names пусто — все, для которых есть обновление) и
|
||||||
// и sha256 каждого файла), атомарно заменяет бинари и завершает процесс с
|
// передать установку root-установщику. Если среди них ядро, bj-server перезапустится до ответа.
|
||||||
// ненулевым кодом — systemd (Restart=on-failure) поднимает новую версию.
|
func (u *Updater) ApplyUpdate(ctx context.Context, names []string) (string, error) {
|
||||||
func (u *Updater) ApplyUpdate(ctx context.Context) error {
|
|
||||||
// Гейт лицензией: если лицензирование включено — требуется валидная
|
|
||||||
// лицензия с фичей updates. Без лицензирования (открытый режим) — пропускаем.
|
|
||||||
if licensingEnabled(u.rc) {
|
if licensingEnabled(u.rc) {
|
||||||
ls := licenseStatus(u.rc)
|
ls := licenseStatus(u.rc)
|
||||||
if !ls.Valid {
|
if !ls.Valid {
|
||||||
return fmt.Errorf("обновления заблокированы — лицензия: %s", ls.Message)
|
return "", fmt.Errorf("обновления заблокированы — лицензия: %s", ls.Message)
|
||||||
}
|
}
|
||||||
if !ls.AllowsUpdates {
|
if !ls.AllowsUpdates {
|
||||||
return fmt.Errorf("обновления не входят в план %q", ls.Plan)
|
return "", fmt.Errorf("обновления не входят в план %q", ls.Plan)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
cl, err := u.updateClient()
|
cl, err := u.updateClient()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return "", err
|
||||||
}
|
}
|
||||||
m, err := cl.FetchManifest(ctx)
|
sm, m, err := cl.FetchSigned(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("манифест: %w", err)
|
return "", fmt.Errorf("манифест: %w", err)
|
||||||
}
|
}
|
||||||
if !release.IsNewer(m.Version, BuildVersion) {
|
plan := release.Plan("", m, release.LoadInstalled(release.InstalledFile), BuildVersion)
|
||||||
return fmt.Errorf("обновление не требуется (текущая %s, доступна %s)", BuildVersion, m.Version)
|
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
|
cleanupStaging(24 * time.Hour)
|
||||||
for _, a := range m.Artifacts {
|
staging := filepath.Join(updateStagingDir, fmt.Sprintf("%d", time.Now().UnixNano()))
|
||||||
dst, ok := installPaths[a.Name]
|
if err := os.MkdirAll(staging, 0o750); err != nil {
|
||||||
if !ok {
|
return "", fmt.Errorf("каталог подготовки: %w", err)
|
||||||
continue // скрипты/SQL не обновляем на лету
|
}
|
||||||
}
|
b, _ := json.Marshal(sm)
|
||||||
dir := dirOf(dst)
|
if err := os.WriteFile(filepath.Join(staging, "manifest.json"), b, 0o640); err != nil {
|
||||||
path, err := cl.DownloadArtifact(ctx, a, dir)
|
return "", err
|
||||||
if err != nil {
|
}
|
||||||
return fmt.Errorf("скачивание %s: %w", a.Name, err)
|
var list []string
|
||||||
}
|
var req struct {
|
||||||
// DownloadArtifact кладёт файл под именем a.File; если целевое имя
|
Names []string `json:"names"`
|
||||||
// иное — переименуем атомарно.
|
}
|
||||||
if path != dst {
|
for _, c := range selected {
|
||||||
if err := os.Rename(path, dst); err != nil {
|
for _, a := range m.Artifacts {
|
||||||
return fmt.Errorf("установка %s: %w", a.Name, err)
|
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)
|
req.Names = append(req.Names, c.Name)
|
||||||
updated++
|
list = append(list, c.Name+" "+c.Available)
|
||||||
}
|
}
|
||||||
if updated == 0 {
|
rb, _ := json.Marshal(req)
|
||||||
return fmt.Errorf("в манифесте %s нет обновляемых бинарей", m.Version)
|
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)
|
// updateComponentRows — компоненты для раздела «Обновления» без обращения к артефактории:
|
||||||
// Завершаемся с ненулевым кодом — systemd Restart=on-failure поднимет
|
// установленное на этом сервере и версии из последней проверки.
|
||||||
// новый бинарь. Даём пару секунд на флаш логов/ответа.
|
func updateComponentRows(rc *RuntimeConfig) ([]release.ComponentState, bool) {
|
||||||
go func() {
|
rows := release.Plan("", nil, release.LoadInstalled(release.InstalledFile), BuildVersion)
|
||||||
time.Sleep(800 * time.Millisecond)
|
avail := rc.Snapshot().Update.Components
|
||||||
os.Exit(42)
|
var out []release.ComponentState
|
||||||
}()
|
any := false
|
||||||
return nil
|
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 — фоновая авто-проверка обновлений (если включена).
|
// updateLoop — фоновая авто-проверка обновлений (если включена).
|
||||||
@@ -174,7 +252,7 @@ func (u *Updater) updateLoop(ctx context.Context) {
|
|||||||
cctx, cancel := context.WithTimeout(ctx, 90*time.Second)
|
cctx, cancel := context.WithTimeout(ctx, 90*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
if st, err := u.CheckForUpdate(cctx); err == nil && st.HasUpdate {
|
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():
|
case <-ctx.Done():
|
||||||
return
|
return
|
||||||
case <-time.After(time.Minute):
|
case <-time.After(time.Minute):
|
||||||
|
cleanupStaging(time.Hour)
|
||||||
check()
|
check()
|
||||||
}
|
}
|
||||||
for {
|
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 "."
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -922,21 +922,26 @@
|
|||||||
<h1>Обновления</h1>
|
<h1>Обновления</h1>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>Версия bj-server</h2>
|
<h2>Компоненты</h2>
|
||||||
<div style="display:flex;align-items:center;gap:14px;flex-wrap:wrap;margin-top:6px">
|
<p class="muted">Каждый компонент обновляется отдельно: своя версия в релизе, перезапускается только его служба. Файлы ставит root-установщик после повторной проверки подписи и контрольных сумм. Модули, которых нет на этом сервере, обновлением не ставятся.</p>
|
||||||
<span class="stat-value" style="font-size:20px">{{.CurrentVersion}}</span>
|
<div style="overflow-x:auto"><table style="margin-top:8px">
|
||||||
{{if .Settings.Update.Available}}
|
<thead><tr><th>Компонент</th><th>Установлена</th><th>Доступна</th><th>Служба</th><th></th></tr></thead>
|
||||||
{{if ne .Settings.Update.Available .CurrentVersion}}
|
<tbody>
|
||||||
<span class="hero-status warn">● Доступна {{.Settings.Update.Available}}</span>
|
{{range .Components}}<tr>
|
||||||
<form method="post" action="/admin/setup/update/apply" style="margin:0" onsubmit="return confirm('Скачать и установить {{.Settings.Update.Available}}? bj-server перезапустится.');">
|
<td><strong>{{.Title}}</strong><div class="muted" style="font-size:11px"><code>{{.Name}}</code></div></td>
|
||||||
<button type="submit" class="btn btn-ok">⬆ Установить {{.Settings.Update.Available}}</button>
|
<td>{{if .Present}}{{if .Installed}}{{.Installed}}{{else}}<span class="muted">неизвестна</span>{{end}}{{else}}<span class="muted">не установлен</span>{{end}}</td>
|
||||||
</form>
|
<td>{{if .Available}}{{.Available}}{{if .Update}} <span class="hero-status warn">новее</span>{{end}}{{else}}<span class="muted">нет данных</span>{{end}}</td>
|
||||||
{{else}}<span class="hero-status ok">● Актуальная версия</span>{{end}}
|
<td><code style="font-size:11px">{{.Unit}}</code></td>
|
||||||
{{end}}
|
<td>{{if .Update}}<form method="post" action="/admin/setup/update/apply" style="margin:0" onsubmit="return confirm('Обновить компонент до {{.Available}}? Его служба перезапустится.');"><input type="hidden" name="section" value="sec-update"><input type="hidden" name="component" value="{{.Name}}"><button type="submit" class="btn btn-ok">Обновить</button></form>{{end}}</td>
|
||||||
<form method="post" action="/admin/setup/update/check" style="margin:0"><button type="submit" class="btn btn-secondary">Проверить обновления</button></form>
|
</tr>{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table></div>
|
||||||
|
<div style="display:flex;gap:10px;flex-wrap:wrap;margin-top:12px">
|
||||||
|
{{if .UpdatesAvailable}}<form method="post" action="/admin/setup/update/apply" style="margin:0" onsubmit="return confirm('Обновить все компоненты, для которых есть новая версия?');"><input type="hidden" name="section" value="sec-update"><button type="submit" class="btn btn-ok">Обновить всё</button></form>{{end}}
|
||||||
|
<form method="post" action="/admin/setup/update/check" style="margin:0"><input type="hidden" name="section" value="sec-update"><button type="submit" class="btn btn-secondary">Проверить обновления</button></form>
|
||||||
</div>
|
</div>
|
||||||
{{if .Settings.Update.Notes}}<p class="muted" style="margin-top:10px">Что нового: {{.Settings.Update.Notes}}</p>{{end}}
|
{{if .Settings.Update.Notes}}<p class="muted" style="margin-top:10px">Что нового в релизе {{.Settings.Update.Available}}: {{.Settings.Update.Notes}}</p>{{end}}
|
||||||
{{if not .Settings.Update.LastCheck.IsZero}}<p class="muted" style="margin-top:6px;font-size:12px">Последняя проверка: {{.Settings.Update.LastCheck.Format "02.01.2006 15:04"}} — {{.Settings.Update.LastResult}}</p>{{end}}
|
{{if not .Settings.Update.LastCheck.IsZero}}<p class="muted" style="margin-top:6px;font-size:12px">Последняя проверка: {{.Settings.Update.LastCheck.Format "02.01.2006 15:04"}}. {{.Settings.Update.LastResult}}</p>{{end}}
|
||||||
{{if and .License.Present (not .License.AllowsUpdates)}}<p class="muted" style="margin-top:6px;font-size:12px;color:var(--warn)">Текущий план «{{.License.Plan}}» не включает обновления.</p>{{end}}
|
{{if and .License.Present (not .License.AllowsUpdates)}}<p class="muted" style="margin-top:6px;font-size:12px;color:var(--warn)">Текущий план «{{.License.Plan}}» не включает обновления.</p>{{end}}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -946,7 +951,7 @@
|
|||||||
<form method="post" action="/admin/setup/update" style="margin-top:12px;display:grid;gap:10px;max-width:680px">
|
<form method="post" action="/admin/setup/update" style="margin-top:12px;display:grid;gap:10px;max-width:680px">
|
||||||
<label>URL артефактории</label>
|
<label>URL артефактории</label>
|
||||||
<input type="text" name="base_url" value="{{.Settings.Update.BaseURL}}" placeholder="https://updates.example.com">
|
<input type="text" name="base_url" value="{{.Settings.Update.BaseURL}}" placeholder="https://updates.example.com">
|
||||||
<label>Канal</label>
|
<label>Канал</label>
|
||||||
<select name="channel">
|
<select name="channel">
|
||||||
<option value="stable" {{if eq .Settings.Update.Channel "stable"}}selected{{end}}>stable — стабильный</option>
|
<option value="stable" {{if eq .Settings.Update.Channel "stable"}}selected{{end}}>stable — стабильный</option>
|
||||||
<option value="beta" {{if eq .Settings.Update.Channel "beta"}}selected{{end}}>beta — предварительный</option>
|
<option value="beta" {{if eq .Settings.Update.Channel "beta"}}selected{{end}}>beta — предварительный</option>
|
||||||
|
|||||||
@@ -39,24 +39,32 @@ func NewClient(baseURL, channel, pubB64 string) (*Client, error) {
|
|||||||
// FetchManifest скачивает /v1/<channel>/manifest.json и проверяет подпись.
|
// FetchManifest скачивает /v1/<channel>/manifest.json и проверяет подпись.
|
||||||
// Возвращает доверенный Manifest (или ошибку, если подпись не сошлась).
|
// Возвращает доверенный Manifest (или ошибку, если подпись не сошлась).
|
||||||
func (c *Client) FetchManifest(ctx context.Context) (*Manifest, error) {
|
func (c *Client) FetchManifest(ctx context.Context) (*Manifest, error) {
|
||||||
|
_, m, err := c.FetchSigned(ctx)
|
||||||
|
return m, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// FetchSigned — подписанный манифест как есть (для повторной проверки root-установщиком) и
|
||||||
|
// проверенный Manifest.
|
||||||
|
func (c *Client) FetchSigned(ctx context.Context) (*SignedManifest, *Manifest, error) {
|
||||||
url := fmt.Sprintf("%s/v1/%s/manifest.json", c.BaseURL, c.Channel)
|
url := fmt.Sprintf("%s/v1/%s/manifest.json", c.BaseURL, c.Channel)
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
resp, err := c.HTTP.Do(req)
|
resp, err := c.HTTP.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("release: запрос манифеста: %w", err)
|
return nil, nil, fmt.Errorf("release: запрос манифеста: %w", err)
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
return nil, fmt.Errorf("release: манифест HTTP %d", resp.StatusCode)
|
return nil, nil, fmt.Errorf("release: манифест HTTP %d", resp.StatusCode)
|
||||||
}
|
}
|
||||||
var sm SignedManifest
|
var sm SignedManifest
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&sm); err != nil {
|
if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&sm); err != nil {
|
||||||
return nil, fmt.Errorf("release: разбор манифеста: %w", err)
|
return nil, nil, fmt.Errorf("release: разбор манифеста: %w", err)
|
||||||
}
|
}
|
||||||
return Verify(&sm, c.Pub) // здесь проверяется подпись
|
m, err := Verify(&sm, c.Pub) // здесь проверяется подпись
|
||||||
|
return &sm, m, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// DownloadArtifact скачивает артефакт в destDir, проверяет sha256/размер по
|
// DownloadArtifact скачивает артефакт в destDir, проверяет sha256/размер по
|
||||||
|
|||||||
@@ -0,0 +1,161 @@
|
|||||||
|
package release
|
||||||
|
|
||||||
|
// Компоненты bj, которые обновляются независимо. У каждого свой файл в манифесте релиза и своя
|
||||||
|
// версия (Artifact.Version), путь установки и служба systemd. Список общий для bj-server (что
|
||||||
|
// показывать и предлагать к обновлению), root-установщика cmd/bj-component-install (что разрешено
|
||||||
|
// ставить и куда) и cmd/bj-release (какие файлы исполняемые).
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Component — независимо обновляемая часть bj.
|
||||||
|
type Component struct {
|
||||||
|
Name string // логическое имя артефакта в манифесте
|
||||||
|
Title string
|
||||||
|
Path string // куда устанавливать
|
||||||
|
Unit string // служба systemd, перезапускаемая после установки ("" — без перезапуска)
|
||||||
|
Exec bool // исполняемый файл
|
||||||
|
Core bool // ядро: ставится всегда; модули — только если уже установлены на этом сервере
|
||||||
|
}
|
||||||
|
|
||||||
|
// Components — все компоненты bj. Порядок — порядок показа и установки (ядро перезапускается последним).
|
||||||
|
var Components = []Component{
|
||||||
|
{Name: "bj-server", Title: "Ядро bj: переводы M2M, DEPO, QUIK МНП, ЛК ЕСИА", Path: "/opt/bj/bj-server", Unit: "bj-server", Exec: true, Core: true},
|
||||||
|
{Name: "crypto-service.jar", Title: "Криптосервис", Path: "/opt/bj/crypto-service.jar", Unit: "bj-crypto"},
|
||||||
|
{Name: "bj-lk", Title: "Личный кабинет клиента", Path: "/opt/bj/bj-lk", Unit: "bj-lk", Exec: true},
|
||||||
|
{Name: "bj-npr", Title: "Риск-монитор НПР / УДС", Path: "/opt/bj/bj-npr", Unit: "bj-npr", Exec: true},
|
||||||
|
{Name: "bj-quikexport", Title: "Экспорт QUIK", Path: "/opt/bj/bj-quikexport", Unit: "bj-quikexport", Exec: true},
|
||||||
|
}
|
||||||
|
|
||||||
|
// ComponentByName — компонент по имени артефакта.
|
||||||
|
func ComponentByName(name string) (Component, bool) {
|
||||||
|
for _, c := range Components {
|
||||||
|
if c.Name == name {
|
||||||
|
return c, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Component{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// PathAt — путь установки относительно корня root ("" — настоящий корень; иначе проверка в песочнице).
|
||||||
|
func (c Component) PathAt(root string) string {
|
||||||
|
if root == "" {
|
||||||
|
return c.Path
|
||||||
|
}
|
||||||
|
return filepath.Join(root, c.Path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// InstalledFile — установленные версии компонентов; пишет bj-component-install.
|
||||||
|
const InstalledFile = "/var/lib/bj/.bj/components.json"
|
||||||
|
|
||||||
|
// Installed — запись об установленном компоненте.
|
||||||
|
type Installed struct {
|
||||||
|
Version string `json:"version"`
|
||||||
|
SHA256 string `json:"sha256"`
|
||||||
|
InstalledAt time.Time `json:"installed_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadInstalled — установленные версии (пустая карта, если файла нет).
|
||||||
|
func LoadInstalled(path string) map[string]Installed {
|
||||||
|
m := map[string]Installed{}
|
||||||
|
if b, err := os.ReadFile(path); err == nil {
|
||||||
|
_ = json.Unmarshal(b, &m)
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveInstalled — атомарная запись установленных версий.
|
||||||
|
func SaveInstalled(path string, m map[string]Installed) error {
|
||||||
|
b, err := json.MarshalIndent(m, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
tmp := path + ".tmp"
|
||||||
|
if err := os.WriteFile(tmp, b, 0o644); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.Rename(tmp, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ComponentState — компонент на этом сервере против манифеста.
|
||||||
|
type ComponentState struct {
|
||||||
|
Component
|
||||||
|
Present bool // файл установлен на этом сервере
|
||||||
|
Installed string // установленная версия ("" — неизвестна)
|
||||||
|
Available string // версия в манифесте ("" — компонента нет в релизе)
|
||||||
|
SameFile bool // установленный файл совпадает с файлом релиза
|
||||||
|
Update bool // можно обновить
|
||||||
|
}
|
||||||
|
|
||||||
|
// Plan — что можно обновить. m == nil — только состояние установленного (без сети).
|
||||||
|
// selfVersion — версия работающего bj-server, если записи об установке ещё нет.
|
||||||
|
func Plan(root string, m *Manifest, installed map[string]Installed, selfVersion string) []ComponentState {
|
||||||
|
var out []ComponentState
|
||||||
|
for _, c := range Components {
|
||||||
|
st := ComponentState{Component: c}
|
||||||
|
sha := ""
|
||||||
|
if fi, err := os.Stat(c.PathAt(root)); err == nil && fi.Mode().IsRegular() {
|
||||||
|
st.Present = true
|
||||||
|
sha = cachedHash(c.PathAt(root), fi)
|
||||||
|
}
|
||||||
|
if inst, ok := installed[c.Name]; ok && inst.SHA256 != "" && inst.SHA256 == sha {
|
||||||
|
st.Installed = inst.Version
|
||||||
|
}
|
||||||
|
if st.Installed == "" && c.Core && st.Present {
|
||||||
|
st.Installed = selfVersion
|
||||||
|
}
|
||||||
|
if m != nil {
|
||||||
|
for _, a := range m.Artifacts {
|
||||||
|
if a.Name != c.Name {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
st.Available = a.Version
|
||||||
|
if st.Available == "" {
|
||||||
|
st.Available = m.Version
|
||||||
|
}
|
||||||
|
st.SameFile = sha != "" && a.SHA256 == sha
|
||||||
|
st.Update = (st.Present || c.Core) && !st.SameFile && (st.Installed == "" || IsNewer(st.Available, st.Installed))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out = append(out, st)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// cachedHash — sha256 файла с кэшем по размеру и времени изменения: страница настроек
|
||||||
|
// не пересчитывает десятки мегабайт на каждый показ.
|
||||||
|
var hashCache = struct {
|
||||||
|
sync.Mutex
|
||||||
|
m map[string]hashEntry
|
||||||
|
}{m: map[string]hashEntry{}}
|
||||||
|
|
||||||
|
type hashEntry struct {
|
||||||
|
size int64
|
||||||
|
mod time.Time
|
||||||
|
sha string
|
||||||
|
}
|
||||||
|
|
||||||
|
func cachedHash(path string, fi os.FileInfo) string {
|
||||||
|
hashCache.Lock()
|
||||||
|
e, ok := hashCache.m[path]
|
||||||
|
hashCache.Unlock()
|
||||||
|
if ok && e.size == fi.Size() && e.mod.Equal(fi.ModTime()) {
|
||||||
|
return e.sha
|
||||||
|
}
|
||||||
|
sha, _, err := HashFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
hashCache.Lock()
|
||||||
|
hashCache.m[path] = hashEntry{size: fi.Size(), mod: fi.ModTime(), sha: sha}
|
||||||
|
hashCache.Unlock()
|
||||||
|
return sha
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package release
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func writeComp(t *testing.T, root, name, body string) string {
|
||||||
|
t.Helper()
|
||||||
|
c, _ := ComponentByName(name)
|
||||||
|
p := c.PathAt(root)
|
||||||
|
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(p, []byte(body), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
sha, _, err := HashFile(p)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return sha
|
||||||
|
}
|
||||||
|
|
||||||
|
func state(states []ComponentState, name string) ComponentState {
|
||||||
|
for _, s := range states {
|
||||||
|
if s.Name == name {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ComponentState{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPlanIndependentVersions(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
npr := writeComp(t, root, "bj-npr", "npr 1.0.0")
|
||||||
|
writeComp(t, root, "bj-server", "core old")
|
||||||
|
installed := map[string]Installed{
|
||||||
|
"bj-npr": {Version: "1.0.0", SHA256: npr},
|
||||||
|
"bj-server": {Version: "2.0.0", SHA256: "другой файл"},
|
||||||
|
}
|
||||||
|
m := &Manifest{Version: "2026.09.1", Artifacts: []Artifact{
|
||||||
|
{Name: "bj-server", Version: "2.1.0", SHA256: "new-core"},
|
||||||
|
{Name: "bj-npr", Version: "1.0.0", SHA256: npr},
|
||||||
|
{Name: "bj-quikexport", Version: "1.2.0", SHA256: "qe"},
|
||||||
|
}}
|
||||||
|
st := Plan(root, m, installed, "2.0.0")
|
||||||
|
|
||||||
|
if s := state(st, "bj-npr"); !s.Present || s.Installed != "1.0.0" || !s.SameFile || s.Update {
|
||||||
|
t.Fatalf("bj-npr актуален и не должен обновляться: %+v", s)
|
||||||
|
}
|
||||||
|
// Запись об ядре не совпала по sha — берётся версия работающего bj-server, релиз новее.
|
||||||
|
if s := state(st, "bj-server"); s.Installed != "2.0.0" || s.Available != "2.1.0" || !s.Update {
|
||||||
|
t.Fatalf("ядро должно обновиться до 2.1.0: %+v", s)
|
||||||
|
}
|
||||||
|
// Модуль не установлен на этом сервере — не ставим.
|
||||||
|
if s := state(st, "bj-quikexport"); s.Present || s.Update {
|
||||||
|
t.Fatalf("неустановленный модуль не должен предлагаться: %+v", s)
|
||||||
|
}
|
||||||
|
// Старая версия в релизе не предлагается (без отката).
|
||||||
|
m.Artifacts[1] = Artifact{Name: "bj-npr", Version: "0.9.0", SHA256: "old"}
|
||||||
|
if s := state(Plan(root, m, installed, "2.0.0"), "bj-npr"); s.Update {
|
||||||
|
t.Fatalf("откат на 0.9.0 не должен предлагаться: %+v", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -41,10 +41,10 @@ type Artifact struct {
|
|||||||
// Manifest — описание релиза. Подписывается целиком (каноничный JSON).
|
// Manifest — описание релиза. Подписывается целиком (каноничный JSON).
|
||||||
type Manifest struct {
|
type Manifest struct {
|
||||||
Schema int `json:"schema"`
|
Schema int `json:"schema"`
|
||||||
Version string `json:"version"` // версия релиза, напр. "1.2.0"
|
Version string `json:"version"` // версия релиза, напр. "1.2.0"
|
||||||
Channel string `json:"channel"` // "stable" | "beta"
|
Channel string `json:"channel"` // "stable" | "beta"
|
||||||
ReleasedAt time.Time `json:"released_at"` // время сборки релиза
|
ReleasedAt time.Time `json:"released_at"` // время сборки релиза
|
||||||
Notes string `json:"notes,omitempty"` // что нового (для UI)
|
Notes string `json:"notes,omitempty"` // что нового (для UI)
|
||||||
MinVersion string `json:"min_version,omitempty"` // мин. версия для прямого апгрейда
|
MinVersion string `json:"min_version,omitempty"` // мин. версия для прямого апгрейда
|
||||||
Artifacts []Artifact `json:"artifacts"`
|
Artifacts []Artifact `json:"artifacts"`
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user