Обновление панели из интерфейса и режим обновления в установщике
Установщик стал идемпотентным: на уже настроенной машине он определяет установленную версию, останавливает службу, меняет бинарник и поднимает её обратно, не трогая конфиг, миры и учётные записи. Зависимости при обновлении не переустанавливаются — это лишние минуты 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:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user