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:
zuevav
2026-09-15 20:16:36 +03:00
parent 5115ed7223
commit 2908697363
11 changed files with 720 additions and 114 deletions
+14 -6
View File
@@ -39,24 +39,32 @@ func NewClient(baseURL, channel, pubB64 string) (*Client, error) {
// FetchManifest скачивает /v1/<channel>/manifest.json и проверяет подпись.
// Возвращает доверенный Manifest (или ошибку, если подпись не сошлась).
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)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
return nil, nil, err
}
resp, err := c.HTTP.Do(req)
if err != nil {
return nil, fmt.Errorf("release: запрос манифеста: %w", err)
return nil, nil, fmt.Errorf("release: запрос манифеста: %w", err)
}
defer resp.Body.Close()
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
if err := json.NewDecoder(resp.Body).Decode(&sm); err != nil {
return nil, fmt.Errorf("release: разбор манифеста: %w", err)
if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&sm); err != nil {
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/размер по
+161
View File
@@ -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
}
+66
View File
@@ -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)
}
}
+4 -4
View File
@@ -41,10 +41,10 @@ type Artifact struct {
// Manifest — описание релиза. Подписывается целиком (каноничный JSON).
type Manifest struct {
Schema int `json:"schema"`
Version string `json:"version"` // версия релиза, напр. "1.2.0"
Channel string `json:"channel"` // "stable" | "beta"
ReleasedAt time.Time `json:"released_at"` // время сборки релиза
Notes string `json:"notes,omitempty"` // что нового (для UI)
Version string `json:"version"` // версия релиза, напр. "1.2.0"
Channel string `json:"channel"` // "stable" | "beta"
ReleasedAt time.Time `json:"released_at"` // время сборки релиза
Notes string `json:"notes,omitempty"` // что нового (для UI)
MinVersion string `json:"min_version,omitempty"` // мин. версия для прямого апгрейда
Artifacts []Artifact `json:"artifacts"`
}