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() {
|
||||
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, " версия отдельного файла — в <файл>.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])
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user