fdd70c06dd
Правки установки приезжают обновлением с сервера лицензий, образ ISO пересобирать не нужно. Прежний установщик обновлений bj-installer не знает — он откладывается и ставится после перезапуска bj-server (ensureSplitModules). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014FsEKyJrYF69Cj9ZXzFLRi
295 lines
10 KiB
Go
295 lines
10 KiB
Go
package release
|
|
|
|
// Установка компонентов из каталога подготовки. Общая для root-установщика обновлений
|
|
// (cmd/bj-component-install) и установщика bj (cmd/bj-installer). Ничему из каталога не доверяет:
|
|
// проверяет подпись манифеста и sha256 файлов, ставит только известные компоненты по их путям,
|
|
// не откатывает версию, сохраняет прежний файл (<путь>.bak-ДАТА, пять последних).
|
|
|
|
import (
|
|
"crypto/ed25519"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"io/fs"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"syscall"
|
|
"time"
|
|
)
|
|
|
|
// StagingBase — каталог подготовки обновлений на сервере bj.
|
|
const StagingBase = "/var/lib/bj/update-staging"
|
|
|
|
// InstallOptions — как ставить.
|
|
type InstallOptions struct {
|
|
Root string // корень установки ("" — настоящий; иначе проверка в песочнице)
|
|
PubKey ed25519.PublicKey // открытый ключ подписи релизов
|
|
Restart bool // перезапустить службы установленных компонентов (ядро последним)
|
|
AllowNew bool // ставить модули, которых ещё нет на сервере (первичная установка)
|
|
Units fs.FS // unit-файлы служб (systemd/<служба>.service) для впервые устанавливаемых модулей
|
|
}
|
|
|
|
// InstallResult — итог установки (JSON для bj-server).
|
|
type InstallResult 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"`
|
|
}
|
|
|
|
// InstallStaged — установить компоненты из каталога подготовки: manifest.json (подписанный),
|
|
// request.json {"names": [...]} и файлы компонентов.
|
|
func InstallStaged(staging string, opt InstallOptions) (res InstallResult) {
|
|
fail := func(format string, a ...any) InstallResult {
|
|
res.OK, res.Error = false, fmt.Sprintf(format, a...)
|
|
return res
|
|
}
|
|
if staging == "" {
|
|
return fail("не указан каталог подготовки")
|
|
}
|
|
staging = filepath.Clean(staging)
|
|
if opt.Root == "" && !strings.HasPrefix(staging, StagingBase+"/") {
|
|
return fail("каталог подготовки должен быть внутри %s", StagingBase)
|
|
}
|
|
if len(opt.PubKey) != ed25519.PublicKeySize {
|
|
return fail("не задан открытый ключ подписи релизов")
|
|
}
|
|
var sm SignedManifest
|
|
if err := readStagedJSON(staging, "manifest.json", &sm); err != nil {
|
|
return fail("манифест: %v", err)
|
|
}
|
|
m, err := Verify(&sm, opt.PubKey)
|
|
if err != nil {
|
|
return fail("подпись манифеста не прошла проверку: %v", err)
|
|
}
|
|
res.Release = m.Version
|
|
var req struct {
|
|
Names []string `json:"names"`
|
|
New []string `json:"new"` // выделенные из ядра модули, которые ставятся впервые
|
|
}
|
|
if err := readStagedJSON(staging, "request.json", &req); err != nil {
|
|
return fail("запрос: %v", err)
|
|
}
|
|
if len(req.Names) == 0 {
|
|
return fail("не выбраны компоненты")
|
|
}
|
|
installedPath := InstalledFile
|
|
if opt.Root != "" {
|
|
installedPath = filepath.Join(opt.Root, installedPath)
|
|
}
|
|
installed := LoadInstalled(installedPath)
|
|
|
|
// Все проверки до любой установки: всё или ничего.
|
|
type job struct {
|
|
c Component
|
|
a Artifact
|
|
file string
|
|
}
|
|
var jobs []job
|
|
for _, name := range req.Names {
|
|
c, ok := ComponentByName(name)
|
|
if !ok {
|
|
return fail("неизвестный компонент %q", name)
|
|
}
|
|
var art *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)
|
|
}
|
|
if art.Version == "" {
|
|
art.Version = m.Version
|
|
}
|
|
if _, err := os.Stat(c.PathAt(opt.Root)); err != nil && !c.Core && !opt.AllowNew && !(c.FromCore && contains(req.New, name)) {
|
|
return fail("%s не установлен на этом сервере: модуль ставится установщиком, а не обновлением", name)
|
|
}
|
|
if prev, ok := installed[name]; ok && prev.Version != "" && IsNewer(prev.Version, art.Version) {
|
|
return fail("%s: установлена %s, в релизе более старая %s, откат не выполняется", name, prev.Version, art.Version)
|
|
}
|
|
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 := VerifyArtifact(file, *art); err != nil {
|
|
return fail("%s: %v", name, err)
|
|
}
|
|
jobs = append(jobs, job{c: c, a: *art, file: file})
|
|
}
|
|
sort.SliceStable(jobs, func(i, j int) bool { return componentOrder(jobs[i].c.Name) < componentOrder(jobs[j].c.Name) })
|
|
|
|
ts := time.Now().Format("20060102-150405")
|
|
var units, newUnits []string
|
|
for _, j := range jobs {
|
|
if err := installFile(j.file, j.c.PathAt(opt.Root), j.c.Exec, ts); err != nil {
|
|
return fail("установка %s: %v", j.c.Name, err)
|
|
}
|
|
installed[j.c.Name] = 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)
|
|
added, err := ensureUnit(opt.Root, opt.Units, j.c.Unit)
|
|
if err != nil {
|
|
return fail("служба %s: %v", j.c.Unit, err)
|
|
}
|
|
if added {
|
|
newUnits = append(newUnits, j.c.Unit)
|
|
}
|
|
}
|
|
}
|
|
if len(newUnits) > 0 && opt.Root == "" {
|
|
if out, err := exec.Command("systemctl", "daemon-reload").CombinedOutput(); err != nil {
|
|
return fail("systemctl daemon-reload: %v: %s", err, strings.TrimSpace(string(out)))
|
|
}
|
|
if out, err := exec.Command("systemctl", append([]string{"enable"}, newUnits...)...).CombinedOutput(); err != nil {
|
|
return fail("включение служб %s: %v: %s", strings.Join(newUnits, ", "), err, strings.TrimSpace(string(out)))
|
|
}
|
|
}
|
|
for _, j := range jobs {
|
|
if j.c.Name == "bj-installer" && opt.Root == "" {
|
|
// новые службы и скрипты из обновлённого установщика; перезапуск служб — отдельно ниже
|
|
if out, err := exec.Command(j.c.Path, "refresh-system").CombinedOutput(); err != nil {
|
|
return fail("обновление системных файлов (bj-installer refresh-system): %v: %s", err, strings.TrimSpace(string(out)))
|
|
}
|
|
}
|
|
}
|
|
if err := SaveInstalled(installedPath, installed); err != nil {
|
|
return fail("запись установленных версий: %v", err)
|
|
}
|
|
chownLikeParent(installedPath)
|
|
|
|
if opt.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
|
|
}
|
|
|
|
// ensureUnit — поставить unit-файл службы из комплекта, если его ещё нет (true — поставлен).
|
|
func ensureUnit(root string, units fs.FS, unit string) (bool, error) {
|
|
dst := filepath.Join(root, "/etc/systemd/system", unit+".service")
|
|
if _, err := os.Stat(dst); err == nil || units == nil {
|
|
return false, nil
|
|
}
|
|
b, err := fs.ReadFile(units, "systemd/"+unit+".service")
|
|
if err != nil {
|
|
return false, nil // в комплекте нет — служба ставится установщиком
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
|
|
return false, err
|
|
}
|
|
return true, os.WriteFile(dst, b, 0o644)
|
|
}
|
|
|
|
func contains(list []string, v string) bool {
|
|
for _, x := range list {
|
|
if x == v {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func componentOrder(name string) int {
|
|
for i, c := range Components {
|
|
if c.Name == name {
|
|
return i
|
|
}
|
|
}
|
|
return len(Components)
|
|
}
|
|
|
|
func readStagedJSON(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)
|
|
}
|
|
|
|
// installFile — копия прежнего файла, запись нового рядом и атомарная замена.
|
|
func installFile(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 := copyFileMode(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 := copyFileMode(src, tmp, mode); err != nil {
|
|
return err
|
|
}
|
|
return os.Rename(tmp, dst)
|
|
}
|
|
|
|
func copyFileMode(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))
|
|
}
|
|
}
|
|
}
|