Обновление панели из интерфейса и режим обновления в установщике
CI / test (push) Successful in 21s
Release / release (push) Successful in 27s

Установщик стал идемпотентным: на уже настроенной машине он определяет
установленную версию, останавливает службу, меняет бинарник и поднимает её
обратно, не трогая конфиг, миры и учётные записи. Зависимости при
обновлении не переустанавливаются — это лишние минуты apt.

Кнопки «Обновить» и «Перезапустить панель» появились на вкладке
«Настройки» вместе с текущей версией и описанием нового релиза.

Панель работает от непривилегированного пользователя и не может ни
заменить свой бинарник в /usr/local/bin, ни дёрнуть systemctl. Вместо
того чтобы расширять её права, установщик кладёт root-скрипт
pzmanager-admin и разрешает сервисному пользователю через sudoers ровно
эту одну команду. Скрипт сверяет sha256 скачанного бинарника с
checksums.txt релиза. Если правила sudo нет, кнопки не будет — панель
покажет команду для терминала.

Перед обновлением и перезапуском игровой сервер останавливается штатным
quit: иначе его погасил бы systemd уже в момент рестарта, и ожидание
растянулось бы на таймаут остановки.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-04 19:37:53 +03:00
co-authored by Claude Opus 5
parent 49d549463a
commit c0797be745
13 changed files with 833 additions and 25 deletions
+243
View File
@@ -0,0 +1,243 @@
// Package updater проверяет наличие новой версии панели в Gitea и запускает
// обновление.
//
// Сама панель работает от непривилегированного пользователя и не может ни
// заменить свой бинарник в /usr/local/bin, ни дёрнуть systemctl. Поэтому
// привилегированные действия вынесены в отдельный root-скрипт
// pzmanager-admin, разрешённый сервисному пользователю через sudoers; здесь
// только его вызов.
package updater
import (
"bufio"
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"os/exec"
"strconv"
"strings"
"time"
)
// adminCommand — имя root-скрипта, который ставит установщик.
const adminCommand = "pzmanager-admin"
// ErrNoAdmin возвращается, когда обновление из панели недоступно.
var ErrNoAdmin = errors.New("обновление из панели недоступно: нет pzmanager-admin или правила sudo")
// Info — то, что панель показывает в разделе обновления.
type Info struct {
Current string `json:"current"`
Latest string `json:"latest"`
Notes string `json:"notes"`
Published int64 `json:"published"`
URL string `json:"url"`
// Available — есть ли версия новее установленной.
Available bool `json:"available"`
// CanApply — сможет ли панель обновиться сама.
CanApply bool `json:"can_apply"`
// ManualCommand — что выполнить в терминале, если сама не может.
ManualCommand string `json:"manual_command"`
// Error — почему не удалось узнать последнюю версию.
Error string `json:"error,omitempty"`
}
// Updater знает, где искать релизы и как позвать привилегированный скрипт.
type Updater struct {
// Repo — адрес репозитория вида https://gitea.example/owner/repo.
Repo string
// Version — версия текущей сборки.
Version string
HTTP *http.Client
}
// New создаёт обновлятор.
func New(repo, version string) *Updater {
return &Updater{
Repo: strings.TrimSuffix(strings.TrimSuffix(repo, "/"), ".git"),
Version: version,
HTTP: &http.Client{Timeout: 15 * time.Second},
}
}
// apiBase превращает адрес репозитория в базовый адрес API Gitea.
func (u *Updater) apiBase() (string, error) {
scheme, rest, found := strings.Cut(u.Repo, "://")
if !found {
return "", fmt.Errorf("не разобран адрес репозитория %q", u.Repo)
}
host, path, found := strings.Cut(rest, "/")
if !found || path == "" {
return "", fmt.Errorf("в адресе %q нет владельца и репозитория", u.Repo)
}
return fmt.Sprintf("%s://%s/api/v1/repos/%s", scheme, host, path), nil
}
// Check спрашивает у Gitea последний релиз и сравнивает его с текущей версией.
func (u *Updater) Check(ctx context.Context) Info {
info := Info{
Current: u.Version,
CanApply: u.CanApply(ctx),
ManualCommand: u.manualCommand(),
}
api, err := u.apiBase()
if err != nil {
info.Error = err.Error()
return info
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, api+"/releases/latest", nil)
if err != nil {
info.Error = err.Error()
return info
}
resp, err := u.HTTP.Do(req)
if err != nil {
info.Error = "не удалось связаться с " + u.Repo
return info
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
info.Error = "в репозитории ещё нет релизов"
return info
}
if resp.StatusCode != http.StatusOK {
info.Error = "репозиторий ответил " + resp.Status
return info
}
var release struct {
TagName string `json:"tag_name"`
Body string `json:"body"`
HTMLURL string `json:"html_url"`
PublishedAt time.Time `json:"published_at"`
}
if err := json.NewDecoder(resp.Body).Decode(&release); err != nil {
info.Error = "ответ репозитория не разобран: " + err.Error()
return info
}
info.Latest = release.TagName
info.Notes = release.Body
info.URL = release.HTMLURL
if !release.PublishedAt.IsZero() {
info.Published = release.PublishedAt.Unix()
}
info.Available = IsNewer(u.Version, release.TagName)
return info
}
// CanApply проверяет, что привилегированный скрипт на месте и sudo пускает к
// нему без пароля. Проверяем вызовом: наличие файла ещё не значит, что
// правило sudoers действует.
func (u *Updater) CanApply(ctx context.Context) bool {
if _, err := exec.LookPath("sudo"); err != nil {
return false
}
if _, err := exec.LookPath(adminCommand); err != nil {
return false
}
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
return exec.CommandContext(ctx, "sudo", "-n", adminCommand, "version").Run() == nil
}
func (u *Updater) manualCommand() string {
return "curl -fsSL " + u.Repo + "/raw/branch/main/deploy/get.sh | sudo bash"
}
// Apply обновляет панель до указанной версии (пустая строка — до последней).
// Скрипт перезапускает сервис, поэтому вызов обычно не возвращается: процесс
// получает SIGTERM в середине работы.
func (u *Updater) Apply(ctx context.Context, log func(string), version string) error {
if !u.CanApply(ctx) {
return ErrNoAdmin
}
args := []string{"-n", adminCommand, "update"}
if version != "" {
args = append(args, version)
}
return u.run(ctx, log, args)
}
// Restart перезапускает сервис панели.
func (u *Updater) Restart(ctx context.Context, log func(string)) error {
if !u.CanApply(ctx) {
return ErrNoAdmin
}
return u.run(ctx, log, []string{"-n", adminCommand, "restart"})
}
// run выполняет sudo-команду, отдавая её вывод построчно в log.
func (u *Updater) run(ctx context.Context, log func(string), args []string) error {
cmd := exec.CommandContext(ctx, "sudo", args...)
stdout, err := cmd.StdoutPipe()
if err != nil {
return err
}
cmd.Stderr = cmd.Stdout
if err := cmd.Start(); err != nil {
return err
}
scanner := bufio.NewScanner(stdout)
for scanner.Scan() {
log(scanner.Text())
}
return cmd.Wait()
}
// IsNewer сравнивает версии вида v1.2.3. Сборка без версии ("dev") считается
// всегда устаревшей: понять, что в ней собрано, всё равно нельзя.
func IsNewer(current, latest string) bool {
if latest == "" {
return false
}
if current == "" || current == "dev" {
return true
}
if current == latest {
return false
}
cur, curOK := parseVersion(current)
lat, latOK := parseVersion(latest)
if !curOK || !latOK {
// Нестандартные версии сравнивать бессмысленно; считаем, что новее
// то, что отличается от текущего.
return current != latest
}
for i := range cur {
if cur[i] != lat[i] {
return lat[i] > cur[i]
}
}
return false
}
// parseVersion разбирает "v1.2.3" (и "1.2") в три числа.
func parseVersion(v string) ([3]int, bool) {
var out [3]int
v = strings.TrimPrefix(strings.TrimSpace(v), "v")
// Суффиксы вида -rc1 в сравнении чисел не участвуют.
if idx := strings.IndexAny(v, "-+"); idx != -1 {
v = v[:idx]
}
parts := strings.Split(v, ".")
if len(parts) == 0 || len(parts) > 3 {
return out, false
}
for i, part := range parts {
n, err := strconv.Atoi(part)
if err != nil {
return out, false
}
out[i] = n
}
return out, true
}
+59
View File
@@ -0,0 +1,59 @@
package updater
import "testing"
func TestIsNewer(t *testing.T) {
cases := []struct {
current, latest string
want bool
}{
{"v1.0.0", "v1.0.1", true},
{"v1.0.0", "v1.1.0", true},
{"v1.0.0", "v2.0.0", true},
{"v1.2.3", "v1.2.3", false},
{"v1.2.3", "v1.2.2", false},
{"v2.0.0", "v1.9.9", false},
// Сборка из рабочей копии не знает, что в ней, — предлагаем релиз.
{"dev", "v1.0.0", true},
{"", "v1.0.0", true},
// Без релизов обновляться некуда.
{"v1.0.0", "", false},
// Разное число компонентов.
{"v1.0", "v1.0.1", true},
{"1.0.0", "v1.0.0", false},
// Предрелизные суффиксы в сравнении чисел не участвуют.
{"v1.0.0-rc1", "v1.0.0", false},
{"v1.0.0", "v1.0.1-rc1", true},
}
for _, c := range cases {
if got := IsNewer(c.current, c.latest); got != c.want {
t.Errorf("IsNewer(%q, %q) = %v, ожидалось %v", c.current, c.latest, got, c.want)
}
}
}
func TestAPIBase(t *testing.T) {
cases := map[string]string{
"https://gitea.hsrv.site/mrleo1nid/pzmanager": "https://gitea.hsrv.site/api/v1/repos/mrleo1nid/pzmanager",
"https://gitea.hsrv.site/mrleo1nid/pzmanager.git": "https://gitea.hsrv.site/api/v1/repos/mrleo1nid/pzmanager",
"https://gitea.hsrv.site/mrleo1nid/pzmanager/": "https://gitea.hsrv.site/api/v1/repos/mrleo1nid/pzmanager",
"http://192.168.0.5:3000/team/pzmanager": "http://192.168.0.5:3000/api/v1/repos/team/pzmanager",
}
for repo, want := range cases {
got, err := New(repo, "dev").apiBase()
if err != nil {
t.Errorf("apiBase(%q): %v", repo, err)
continue
}
if got != want {
t.Errorf("apiBase(%q) = %q, ожидалось %q", repo, got, want)
}
}
for _, bad := range []string{"gitea.hsrv.site/owner/repo", "https://gitea.hsrv.site", ""} {
if _, err := New(bad, "dev").apiBase(); err == nil {
t.Errorf("адрес %q принят, ожидалась ошибка", bad)
}
}
}