Обновления из файла для закрытого контура: пакет с портала → проверка подписи → ядро, потом модули
- release: пакет обновления (ExtractBundle/ReadStagedManifest/WriteBundle), Artifact.MinCore, план блокирует модуль, которому нужно ядро новее, чем будет после установки - bj-release: модули получают min_core = версия релиза - настройки → Обновления: «Обновить из файла» (zip до 2 ГБ), фон как у сетевого обновления, причина блокировки в таблице компонентов Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q4YVo4UMopqT5DzicqpPjL
This commit is contained in:
@@ -0,0 +1,142 @@
|
||||
package release
|
||||
|
||||
// bundle.go — пакет обновления одним файлом для закрытого контура.
|
||||
//
|
||||
// Сервер, у которого нет выхода к серверу обновлений, получает обновление файлом: zip с
|
||||
// подписанным манифестом и файлами компонентов, скачанный с портала клиента. Файл
|
||||
// распаковывается в каталог подготовки и дальше идёт тем же путём, что и обновление по
|
||||
// сети: проверка подписи и хешей, план по версиям, установка ядра, потом модулей.
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// BundleManifestName — имя манифеста внутри пакета.
|
||||
const BundleManifestName = "manifest.json"
|
||||
|
||||
// maxBundleFile — предел одного файла в пакете (защита от «zip-бомбы»).
|
||||
const maxBundleFile = 2 << 30
|
||||
|
||||
// ExtractBundle — распаковать пакет в каталог подготовки. Принимаются только обычные файлы
|
||||
// без каталогов в имени; манифест обязателен. Возвращает имена распакованных файлов.
|
||||
func ExtractBundle(zipPath, dest string) ([]string, error) {
|
||||
zr, err := zip.OpenReader(zipPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("файл не является пакетом обновления (zip): %v", err)
|
||||
}
|
||||
defer zr.Close()
|
||||
if err := os.MkdirAll(dest, 0o750); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var names []string
|
||||
seenManifest := false
|
||||
for _, f := range zr.File {
|
||||
if f.FileInfo().IsDir() {
|
||||
continue
|
||||
}
|
||||
// Внутри может быть один каталог верхнего уровня (bj-update-…/) — берём только имя файла.
|
||||
name := path.Base(strings.ReplaceAll(f.Name, "\\", "/"))
|
||||
if name == "." || name == "/" || strings.HasPrefix(name, ".") || strings.ContainsAny(name, "/\\") {
|
||||
continue
|
||||
}
|
||||
if f.UncompressedSize64 > maxBundleFile {
|
||||
return nil, fmt.Errorf("файл %s в пакете слишком большой", name)
|
||||
}
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dst := filepath.Join(dest, name)
|
||||
out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o640)
|
||||
if err != nil {
|
||||
rc.Close()
|
||||
return nil, err
|
||||
}
|
||||
_, err = io.Copy(out, io.LimitReader(rc, maxBundleFile+1))
|
||||
rc.Close()
|
||||
out.Close()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if name == BundleManifestName {
|
||||
seenManifest = true
|
||||
}
|
||||
names = append(names, name)
|
||||
}
|
||||
if !seenManifest {
|
||||
return nil, errors.New("в пакете нет manifest.json — это не пакет обновления bj")
|
||||
}
|
||||
return names, nil
|
||||
}
|
||||
|
||||
// ReadStagedManifest — подписанный манифест из каталога подготовки с проверкой подписи.
|
||||
func ReadStagedManifest(dir string, pub []byte) (*SignedManifest, *Manifest, error) {
|
||||
b, err := os.ReadFile(filepath.Join(dir, BundleManifestName))
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
var sm SignedManifest
|
||||
if err := json.Unmarshal(b, &sm); err != nil {
|
||||
return nil, nil, fmt.Errorf("манифест не разобран: %v", err)
|
||||
}
|
||||
m, err := Verify(&sm, pub)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("подпись манифеста не подтверждена: %v", err)
|
||||
}
|
||||
return &sm, m, nil
|
||||
}
|
||||
|
||||
// WriteBundle — собрать пакет обновления: подписанный манифест и выбранные файлы релиза из
|
||||
// каталога dir. include — какие файлы (Artifact.File) положить; пусто — все.
|
||||
func WriteBundle(w io.Writer, dir string, include func(a Artifact) bool) error {
|
||||
b, err := os.ReadFile(filepath.Join(dir, BundleManifestName))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var sm SignedManifest
|
||||
if err := json.Unmarshal(b, &sm); err != nil {
|
||||
return err
|
||||
}
|
||||
var m Manifest
|
||||
if raw, err := base64.StdEncoding.DecodeString(sm.Payload); err == nil {
|
||||
_ = json.Unmarshal(raw, &m)
|
||||
}
|
||||
zw := zip.NewWriter(w)
|
||||
defer zw.Close()
|
||||
fw, err := zw.CreateHeader(&zip.FileHeader{Name: BundleManifestName, Method: zip.Deflate})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := fw.Write(b); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, a := range m.Artifacts {
|
||||
if include != nil && !include(a) {
|
||||
continue
|
||||
}
|
||||
src, err := os.Open(filepath.Join(dir, a.File))
|
||||
if err != nil {
|
||||
return fmt.Errorf("файл %s релиза не найден: %v", a.File, err)
|
||||
}
|
||||
fw, err := zw.CreateHeader(&zip.FileHeader{Name: a.File, Method: zip.Store}) // бинари уже сжаты плохо — не тратим время
|
||||
if err != nil {
|
||||
src.Close()
|
||||
return err
|
||||
}
|
||||
_, err = io.Copy(fw, src)
|
||||
src.Close()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package release
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Пакет обновления: сборка → распаковка → подпись → план в порядке «ядро, потом модули»;
|
||||
// модуль, требующий более новое ядро, чем будет после установки, помечается заблокированным.
|
||||
func TestBundleRoundTripAndPlanOrder(t *testing.T) {
|
||||
pub, priv, _ := ed25519.GenerateKey(rand.Reader)
|
||||
rel := t.TempDir()
|
||||
write := func(name, body string) Artifact {
|
||||
p := filepath.Join(rel, name)
|
||||
if err := os.WriteFile(p, []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sha, size, _ := HashFile(p)
|
||||
return Artifact{Name: name, File: name, Version: "2.0.0", SHA256: sha, Size: size, Exec: true}
|
||||
}
|
||||
core := write("bj-server", "ядро 2.0.0")
|
||||
lk := write("bj-lk", "кабинет 2.0.0")
|
||||
lk.MinCore = "2.0.0"
|
||||
npr := write("bj-npr", "нпр 2.0.0")
|
||||
npr.MinCore = "2.5.0" // собран под ядро новее этого релиза — ставить нельзя
|
||||
m := &Manifest{Schema: CurrentSchema, Version: "2.0.0", Channel: "stable", Artifacts: []Artifact{lk, npr, core}}
|
||||
sm, err := Sign(m, priv, "k1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, _ := json.Marshal(sm)
|
||||
if err := os.WriteFile(filepath.Join(rel, BundleManifestName), raw, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := WriteBundle(&buf, rel, func(a Artifact) bool { return a.Name != "bj-npr" }); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
zipPath := filepath.Join(t.TempDir(), "bundle.zip")
|
||||
if err := os.WriteFile(zipPath, buf.Bytes(), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
staging := filepath.Join(t.TempDir(), "staging")
|
||||
names, err := ExtractBundle(zipPath, staging)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Join(names, ",") != "manifest.json,bj-lk,bj-server" {
|
||||
t.Fatalf("распаковано: %v", names)
|
||||
}
|
||||
if _, _, err := ReadStagedManifest(staging, pub); err != nil {
|
||||
t.Fatalf("подпись: %v", err)
|
||||
}
|
||||
other, _, _ := ed25519.GenerateKey(rand.Reader)
|
||||
if _, _, err := ReadStagedManifest(staging, other); err == nil {
|
||||
t.Fatal("манифест принят с чужим ключом")
|
||||
}
|
||||
|
||||
// План на сервере с ядром 1.9.0 и кабинетом 1.9.0
|
||||
root := t.TempDir()
|
||||
for _, c := range []string{"bj-server", "bj-lk", "bj-npr"} {
|
||||
comp, _ := ComponentByName(c)
|
||||
_ = os.MkdirAll(filepath.Dir(comp.PathAt(root)), 0o755)
|
||||
_ = os.WriteFile(comp.PathAt(root), []byte("старый "+c), 0o755)
|
||||
}
|
||||
plan := Plan(root, m, map[string]Installed{}, "1.9.0")
|
||||
var order []string
|
||||
byName := map[string]ComponentState{}
|
||||
for _, c := range plan {
|
||||
byName[c.Name] = c
|
||||
if c.Update {
|
||||
order = append(order, c.Name)
|
||||
}
|
||||
}
|
||||
if strings.Join(order, ",") != "bj-server,bj-lk" {
|
||||
t.Fatalf("порядок установки: %v (ядро должно быть первым, npr заблокирован)", order)
|
||||
}
|
||||
if b := byName["bj-npr"]; b.Update || !strings.Contains(b.Blocked, "2.5.0") {
|
||||
t.Fatalf("модуль с требованием к ядру не заблокирован: %+v", b)
|
||||
}
|
||||
// Без ядра в пакете кабинет 2.0.0 на ядро 1.9.0 не встаёт
|
||||
m2 := &Manifest{Schema: CurrentSchema, Version: "2.0.0", Artifacts: []Artifact{lk}}
|
||||
if b := Plan(root, m2, map[string]Installed{}, "1.9.0"); !strings.Contains(findState(b, "bj-lk").Blocked, "сначала обновите ядро") {
|
||||
t.Fatalf("кабинет без нового ядра должен быть заблокирован: %+v", findState(b, "bj-lk"))
|
||||
}
|
||||
// Не zip — понятная ошибка
|
||||
bad := filepath.Join(t.TempDir(), "bad.zip")
|
||||
_ = os.WriteFile(bad, []byte("не архив"), 0o644)
|
||||
if _, err := ExtractBundle(bad, filepath.Join(t.TempDir(), "s")); err == nil || !strings.Contains(err.Error(), "zip") {
|
||||
t.Fatalf("не-zip принят: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func findState(list []ComponentState, name string) ComponentState {
|
||||
for _, c := range list {
|
||||
if c.Name == name {
|
||||
return c
|
||||
}
|
||||
}
|
||||
return ComponentState{}
|
||||
}
|
||||
@@ -100,12 +100,33 @@ type ComponentState struct {
|
||||
SameFile bool // установленный файл совпадает с файлом релиза
|
||||
Update bool // можно обновить
|
||||
New bool // модуль выделен из ядра и ставится впервые (FromCore)
|
||||
// Blocked — почему обновлять нельзя: модулю нужно более новое ядро, чем будет после установки.
|
||||
Blocked string
|
||||
}
|
||||
|
||||
// Plan — что можно обновить. m == nil — только состояние установленного (без сети).
|
||||
// selfVersion — версия работающего bj-server, если записи об установке ещё нет.
|
||||
func Plan(root string, m *Manifest, installed map[string]Installed, selfVersion string) []ComponentState {
|
||||
var out []ComponentState
|
||||
// Версия ядра после установки: если ядро тоже обновляется этим релизом — его новая версия,
|
||||
// иначе — установленная. С ней сверяются требования модулей (Artifact.MinCore).
|
||||
coreAfter := selfVersion
|
||||
if inst, ok := installed["bj-server"]; ok && inst.Version != "" {
|
||||
coreAfter = inst.Version
|
||||
}
|
||||
if m != nil {
|
||||
for _, a := range m.Artifacts {
|
||||
if a.Name == "bj-server" {
|
||||
v := a.Version
|
||||
if v == "" {
|
||||
v = m.Version
|
||||
}
|
||||
if coreAfter == "" || IsNewer(v, coreAfter) {
|
||||
coreAfter = v
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, c := range Components {
|
||||
st := ComponentState{Component: c}
|
||||
sha := ""
|
||||
@@ -130,6 +151,11 @@ func Plan(root string, m *Manifest, installed map[string]Installed, selfVersion
|
||||
}
|
||||
st.SameFile = sha != "" && a.SHA256 == sha
|
||||
st.Update = (st.Present || c.Core) && !st.SameFile && (st.Installed == "" || IsNewer(st.Available, st.Installed))
|
||||
if !c.Core && a.MinCore != "" && coreAfter != "" && IsNewer(a.MinCore, coreAfter) {
|
||||
// Модулю нужно ядро новее, чем будет на сервере: сначала обновляется ядро.
|
||||
st.Blocked = "нужно ядро не ниже " + a.MinCore + " (будет " + coreAfter + ") — сначала обновите ядро"
|
||||
st.Update = false
|
||||
}
|
||||
}
|
||||
}
|
||||
out = append(out, st)
|
||||
@@ -142,7 +168,7 @@ func Plan(root string, m *Manifest, installed map[string]Installed, selfVersion
|
||||
func MarkSplitModules(states []ComponentState, allowed func(feature string) bool) {
|
||||
for i := range states {
|
||||
s := &states[i]
|
||||
if s.FromCore && !s.Present && s.Available != "" && allowed(s.Feature) {
|
||||
if s.FromCore && !s.Present && s.Available != "" && s.Blocked == "" && allowed(s.Feature) {
|
||||
s.Update, s.New = true, true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +36,9 @@ type Artifact struct {
|
||||
SHA256 string `json:"sha256"` // hex-хеш содержимого
|
||||
Size int64 `json:"size"` // размер в байтах
|
||||
Exec bool `json:"exec,omitempty"` // ставить ли +x при установке
|
||||
// MinCore — минимальная версия ядра, с которой работает этот модуль. Модуль из
|
||||
// релиза X собран под ядро X: ставить его на более старое ядро нельзя — сначала ядро.
|
||||
MinCore string `json:"min_core,omitempty"`
|
||||
}
|
||||
|
||||
// Manifest — описание релиза. Подписывается целиком (каноничный JSON).
|
||||
|
||||
Reference in New Issue
Block a user