Files
Bridge-and-Join-s/internal/release/install.go
T
zuevav 648a5ae05f ИШ обновляется через сервер лицензий вместе с bj; наглядный ход установки ИШ
- Дистрибутив ИШ — компонент выпуска igate-dist (версия = версия ИШ). В выпуск
  попадает только одобренный архив: deploy/release/igate-approved (версия и
  sha256), сборка сверяет контрольную сумму. Клиент получает его обновлением;
  bj-server после обновления (и при запуске) сам обновляет ИШ, если на сервере
  он старее; итог — событие в «Новостях». Настройки, каналы и база ИШ
  сохраняются, прежняя версия — в /var/backups.
- Установка «с сервера» берёт самый новый проверенный дистрибутив: с образа или из
  обновления. С сайта НРД — только в режиме разработки.
- Большие файлы: одна прежняя копия вместо пяти; предел скачивания — по размеру.
- Ход установки ИШ — этапами: пройденные, текущий («идёт 0:41 · обычно до 1:30»,
  дольше обычного — предупреждение), журнал под раскрытием; кнопка на время
  установки выключена.
- Установщик: уникальные этапы; igate-cli и сессия ключницы с принудительным
  завершением (timeout -k); зависшая установка (ход не менялся 15 минут)
  снимается повторным запуском; служба установки — не дольше 40 минут.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014FsEKyJrYF69Cj9ZXzFLRi
2026-10-06 17:42:54 +03:00

318 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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"`
Enabled []string `json:"enabled,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 {
// Модуля на сервере ещё не было — его службу включаем, даже если unit-файл уже стоит: установка
// с образа кладёт unit-файлы всех служб сразу (выключенными), а файл модуля приезжает обновлением.
_, statErr := os.Stat(j.c.PathAt(opt.Root))
fresh := statErr != nil
if err := installFile(j.file, j.c.PathAt(opt.Root), j.c.Exec, ts, j.c.Big); 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)
}
// модуль впервые на сервере — его службу включить, но только если unit-файл уже есть: при установке
// с образа ядро ставится раньше своих служб (их кладёт следующий шаг установщика и включает сам)
if added || (fresh && unitInstalled(opt.Root, j.c.Unit)) {
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)))
}
}
res.Enabled = newUnits
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)
}
// unitInstalled — есть ли unit-файл службы (свой или из пакета).
func unitInstalled(root, unit string) bool {
for _, d := range []string{"/etc/systemd/system", "/lib/systemd/system", "/usr/lib/systemd/system"} {
if _, err := os.Stat(filepath.Join(root, d, unit+".service")); err == nil {
return true
}
}
return false
}
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, big bool) error {
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return err
}
if _, err := os.Stat(dst); err == nil {
keep := 5
if big { // дистрибутив в сотни мегабайт — одна прежняя копия
keep = 1
}
pruneBackups(dst, keep-1)
if err := copyFileMode(dst, dst+".bak-"+ts, 0o755); err != nil {
return fmt.Errorf("копия прежнего файла: %w", err)
}
pruneBackups(dst, keep)
}
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))
}
}
}