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); 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) 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)) } } }