Обновления из файла для закрытого контура: пакет с портала → проверка подписи → ядро, потом модули

- 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:
zuevav
2026-09-18 11:27:23 +03:00
parent 5bafd60df6
commit 17d8ec3988
8 changed files with 443 additions and 2 deletions
+5
View File
@@ -120,8 +120,12 @@ func build(args []string) {
fatal("hash %s: %v", e.Name(), err)
}
exec, ok := known[e.Name()]
minCore := ""
if c, isComp := release.ComponentByName(e.Name()); isComp {
exec, ok = c.Exec, true
if !c.Core {
minCore = *version // модуль собран под ядро этого релиза: на более старое ядро не ставится
}
}
// Своя версия компонента: <файл>.version рядом с файлом (независимые обновления).
ver := *version
@@ -139,6 +143,7 @@ func build(args []string) {
SHA256: sha,
Size: size,
Exec: exec,
MinCore: minCore,
})
}
sort.Slice(arts, func(i, j int) bool { return arts[i].Name < arts[j].Name })
+48
View File
@@ -78,6 +78,7 @@ func registerSetup(mux *http.ServeMux, a *admin, rc *RuntimeConfig, svc *Service
mux.HandleFunc("/admin/setup/update", h.saveUpdate)
mux.HandleFunc("/admin/setup/update/check", h.checkUpdate)
mux.HandleFunc("/admin/setup/update/apply", h.applyUpdate)
mux.HandleFunc("/admin/setup/update/upload", h.uploadUpdate)
mux.HandleFunc("/admin/setup/license", h.saveLicense)
mux.HandleFunc("/admin/setup/license/activate", h.licenseActivate)
mux.HandleFunc("/admin/setup/license/renew", h.licenseRenewNow)
@@ -1287,6 +1288,53 @@ func (h *setupHandlers) applyUpdate(w http.ResponseWriter, r *http.Request) {
setupFlash(w, r, "Обновление "+what+" запущено: файлы скачиваются и проверяются, затем перезапускаются службы. Итог — в строке «Последняя проверка» через минуту; при обновлении ядра страница временно не отвечает.")
}
// uploadUpdate — POST /admin/setup/update/upload: пакет обновления файлом (закрытый контур).
// Файл сохраняется в каталог подготовки, дальше — в фоне, как и обновление по сети.
func (h *setupHandlers) uploadUpdate(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
r.Body = http.MaxBytesReader(w, r.Body, 2<<30)
f, hdr, err := r.FormFile("bundle")
if err != nil {
setupFlash(w, r, "Обновление из файла: файл не получен ("+err.Error()+")")
return
}
defer f.Close()
if err := os.MkdirAll(updateStagingDir, 0o750); err != nil {
setupFlash(w, r, "Обновление из файла: каталог подготовки: "+err.Error())
return
}
dst := filepath.Join(updateStagingDir, fmt.Sprintf("upload-%d.zip", time.Now().UnixNano()))
out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o640)
if err != nil {
setupFlash(w, r, "Обновление из файла: "+err.Error())
return
}
n, err := io.Copy(out, f)
out.Close()
if err != nil {
_ = os.Remove(dst)
setupFlash(w, r, "Обновление из файла: не удалось сохранить: "+err.Error())
return
}
rc := h.rc
go func() {
msg, err := NewUpdater(rc).ApplyBundle(context.Background(), dst)
cfg := rc.Snapshot().Update
if err != nil {
msg = "Обновление из файла не применено: " + err.Error()
} else {
msg = "Обновление: " + msg
}
log.Printf("lk-gateway: %s", msg)
cfg.LastResult, cfg.LastCheck = msg, time.Now().UTC()
_ = rc.SaveUpdateSettings(cfg)
}()
setupFlash(w, r, fmt.Sprintf("Файл %s (%d МБ) принят: проверяется подпись, затем ставятся компоненты новее текущих — ядро, потом модули. Итог — в строке «Последняя проверка» через минуту; при обновлении ядра страница временно не отвечает.", hdr.Filename, n>>20))
}
// runTestClaim делает CreateClaim + ждёт финального состояния через GetClaim.
func (h *setupHandlers) runTestClaim() {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+104
View File
@@ -11,6 +11,7 @@ package lkgateway
import (
"context"
"encoding/json"
"errors"
"fmt"
"log"
"os"
@@ -356,3 +357,106 @@ func (u *Updater) updateLoop(ctx context.Context) {
}
}
}
// ApplyBundle — обновление из файла для закрытого контура: пакет с портала клиента
// (подписанный манифест + файлы компонентов). Что ставить, решает тот же план, что и при
// обновлении по сети: всё, что новее установленного, в порядке компонентов — ядро первым,
// модули за ним. Модуль, которому нужно более новое ядро, чем будет после установки,
// пропускается с объяснением.
func (u *Updater) ApplyBundle(ctx context.Context, zipPath string) (string, error) {
defer os.Remove(zipPath)
if licensingEnabled(u.rc) {
ls := licenseStatus(u.rc)
if !ls.Valid {
return "", fmt.Errorf("обновления заблокированы — лицензия: %s", ls.Message)
}
if !ls.AllowsUpdates {
return "", fmt.Errorf("обновления не входят в план %q", ls.Plan)
}
}
pubB64 := u.rc.Snapshot().Update.PublicKey
if pubB64 == "" {
pubB64 = DefaultUpdatePublicKey
}
pub, err := release.ParsePublicKey(pubB64)
if err != nil {
return "", fmt.Errorf("открытый ключ подписи релизов: %w", err)
}
cleanupStaging(24 * time.Hour)
staging := filepath.Join(updateStagingDir, fmt.Sprintf("%d", time.Now().UnixNano()))
files, err := release.ExtractBundle(zipPath, staging)
if err != nil {
_ = os.RemoveAll(staging)
return "", err
}
sm, m, err := release.ReadStagedManifest(staging, pub)
if err != nil {
_ = os.RemoveAll(staging)
return "", err
}
_ = sm
have := map[string]bool{}
for _, f := range files {
have[f] = true
}
plan := release.Plan("", m, release.LoadInstalled(release.InstalledFile), BuildVersion)
release.MarkSplitModules(plan, u.allowed)
var req struct {
Names []string `json:"names"`
New []string `json:"new,omitempty"`
}
var list, skipped []string
for _, c := range plan {
if c.Blocked != "" {
skipped = append(skipped, c.Title+": "+c.Blocked)
continue
}
if !c.Update {
continue
}
if !have[c.Name] { // в пакете может не быть файла модуля (не входит в лицензию клиента)
continue
}
if !u.allowed(c.Feature) && c.Feature != "updates" {
skipped = append(skipped, c.Title+": не входит в лицензию")
continue
}
req.Names = append(req.Names, c.Name)
if c.New {
req.New = append(req.New, c.Name)
}
list = append(list, c.Name+" "+c.Available)
}
if len(req.Names) == 0 {
_ = os.RemoveAll(staging)
msg := fmt.Sprintf("в пакете релиз %s: нечего обновлять, все компоненты актуальны", m.Version)
if len(skipped) > 0 {
msg += "; пропущено: " + strings.Join(skipped, "; ")
}
return "", errors.New(msg)
}
rb, _ := json.Marshal(req)
if err := os.WriteFile(filepath.Join(staging, "request.json"), rb, 0o640); err != nil {
return "", err
}
log.Printf("lk-gateway: обновление из файла (релиз %s): %s — передано root-установщику", m.Version, strings.Join(list, ", "))
wctx, cancel := context.WithTimeout(ctx, 10*time.Minute)
defer cancel()
res, err := runKeymediaOp(wctx, "component-install", map[string]string{"staging": staging})
_ = os.RemoveAll(staging)
if err != nil {
return "", fmt.Errorf("установка: %w", err)
}
msg := fmt.Sprintf("из файла (релиз %s) установлено: %s", m.Version, strings.Join(list, ", "))
if r, ok := res["restarted"].([]any); ok && len(r) > 0 {
var units []string
for _, x := range r {
units = append(units, fmt.Sprint(x))
}
msg += "; перезапущены службы: " + strings.Join(units, ", ")
}
if len(skipped) > 0 {
msg += "; пропущено: " + strings.Join(skipped, "; ")
}
return msg, nil
}
@@ -1020,7 +1020,7 @@ function hbBind(inp,box,list,onPick){
{{range .Components}}<tr>
<td><strong>{{.Title}}</strong><div class="muted" style="font-size:11px"><code>{{.Name}}</code></div></td>
<td>{{if .Present}}{{if .Installed}}{{.Installed}}{{else}}<span class="muted">неизвестна</span>{{end}}{{else}}<span class="muted">не установлен</span>{{end}}</td>
<td>{{if .Available}}{{.Available}}{{if .New}} <span class="hero-status warn">к установке</span>{{else if .Update}} <span class="hero-status warn">новее</span>{{end}}{{else}}<span class="muted">нет данных</span>{{end}}</td>
<td>{{if .Available}}{{.Available}}{{if .New}} <span class="hero-status warn">к установке</span>{{else if .Update}} <span class="hero-status warn">новее</span>{{end}}{{if .Blocked}}<div class="muted" style="font-size:11px;color:var(--warn)">{{.Blocked}}</div>{{end}}{{else}}<span class="muted">нет данных</span>{{end}}</td>
<td><code style="font-size:11px">{{.Unit}}</code></td>
<td>{{if .Update}}<form method="post" action="/admin/setup/update/apply" style="margin:0" onsubmit="return confirm('{{if .New}}Установить модуль {{.Available}}? Будет включена его служба.{{else}}Обновить компонент до {{.Available}}? Его служба перезапустится.{{end}}');"><input type="hidden" name="section" value="sec-update"><input type="hidden" name="component" value="{{.Name}}"><button type="submit" class="btn btn-ok">{{if .New}}Установить{{else}}Обновить{{end}}</button></form>{{end}}</td>
</tr>{{end}}
@@ -1029,8 +1029,14 @@ function hbBind(inp,box,list,onPick){
<div style="display:flex;gap:10px;flex-wrap:wrap;margin-top:12px">
{{if .UpdatesAvailable}}<form method="post" action="/admin/setup/update/apply" style="margin:0" onsubmit="return confirm('Обновить все компоненты, для которых есть новая версия?');"><input type="hidden" name="section" value="sec-update"><button type="submit" class="btn btn-ok">Обновить всё</button></form>{{end}}
<form method="post" action="/admin/setup/update/check" style="margin:0"><input type="hidden" name="section" value="sec-update"><button type="submit" class="btn btn-secondary">Проверить обновления</button></form>
<form method="post" action="/admin/setup/update/upload" enctype="multipart/form-data" style="margin:0;display:flex;gap:8px;align-items:center;flex-wrap:wrap" onsubmit="return this.bundle.files.length>0 && confirm('Установить обновление из файла? Подпись пакета будет проверена; поставятся компоненты новее текущих — сначала ядро, затем модули. Службы перезапустятся.');">
<input type="hidden" name="section" value="sec-update">
<input type="file" name="bundle" accept=".zip,application/zip" required style="max-width:260px">
<button type="submit" class="btn btn-secondary" title="Пакет обновления с портала клиента — для сервера без выхода к серверу обновлений">Обновить из файла</button>
</form>
</div>
{{if .Settings.Update.Notes}}<p class="muted" style="margin-top:10px">Что нового в релизе {{.Settings.Update.Available}}: {{.Settings.Update.Notes}}</p>{{end}}
<p class="muted" style="margin-top:8px;font-size:12px"><b>Закрытый контур.</b> Если у сервера нет выхода к серверу обновлений, скачайте пакет обновления на портале клиента (lic.zetit.ru → Установка → «Пакет обновления») и загрузите его кнопкой «Обновить из файла». Система сама распознаёт, что внутри — ядро, модули или всё вместе — и ставит в правильном порядке. Модуль, которому нужно более новое ядро, чем установлено, будет пропущен с подсказкой обновить сначала ядро.</p>
{{if not .Settings.Update.LastCheck.IsZero}}<p class="muted" style="margin-top:6px;font-size:12px">Последняя проверка: {{.Settings.Update.LastCheck.Format "02.01.2006 15:04"}}. {{.Settings.Update.LastResult}}</p>{{end}}
{{if and .License.Present (not .License.AllowsUpdates)}}<p class="muted" style="margin-top:6px;font-size:12px;color:var(--warn)">Текущий план «{{.License.Plan}}» не включает обновления.</p>{{end}}
</div>
+142
View File
@@ -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
}
+107
View File
@@ -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{}
}
+27 -1
View File
@@ -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
}
}
+3
View File
@@ -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).