// Package updater проверяет наличие новой версии панели в Gitea и запускает // обновление. // // Сама панель работает от непривилегированного пользователя и не может ни // заменить свой бинарник в /usr/local/bin, ни дёрнуть systemctl. Поэтому // привилегированные действия вынесены в отдельный root-скрипт // pzmanager-admin, разрешённый сервисному пользователю через sudoers; здесь // только его вызов. package updater import ( "bufio" "context" "encoding/json" "errors" "fmt" "net/http" "os/exec" "sort" "strconv" "strings" "time" ) // adminCommand — имя root-скрипта, который ставит установщик. const adminCommand = "pzmanager-admin" // ErrNoAdmin возвращается, когда обновление из панели недоступно. var ErrNoAdmin = errors.New("обновление из панели недоступно: нет pzmanager-admin или правила sudo") // ReleaseNote — одна версия в списке изменений. type ReleaseNote struct { Version string `json:"version"` Notes string `json:"notes"` Published int64 `json:"published"` URL string `json:"url"` } // 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"` // Changelog — все версии между установленной и последней, свежие первыми. // Одного описания последнего релиза мало: пропустив пару версий, человек // не увидит, что менялось в промежутке. Changelog []ReleaseNote `json:"changelog"` // 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(), Changelog: []ReleaseNote{}, } api, err := u.apiBase() if err != nil { info.Error = err.Error() return info } // limit с запасом: показываем изменения за все пропущенные версии, но не // тянем историю репозитория целиком. req, err := http.NewRequestWithContext(ctx, http.MethodGet, api+"/releases?limit=30&draft=false", 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.StatusOK { info.Error = "репозиторий ответил " + resp.Status return info } var releases []struct { TagName string `json:"tag_name"` Body string `json:"body"` HTMLURL string `json:"html_url"` PublishedAt time.Time `json:"published_at"` Draft bool `json:"draft"` Prerelease bool `json:"prerelease"` } if err := json.NewDecoder(resp.Body).Decode(&releases); err != nil { info.Error = "ответ репозитория не разобран: " + err.Error() return info } notes := make([]ReleaseNote, 0, len(releases)) for _, r := range releases { if r.Draft || r.Prerelease || r.TagName == "" { continue } note := ReleaseNote{Version: r.TagName, Notes: r.Body, URL: r.HTMLURL} if !r.PublishedAt.IsZero() { note.Published = r.PublishedAt.Unix() } notes = append(notes, note) } if len(notes) == 0 { info.Error = "в репозитории ещё нет релизов" return info } // Gitea отдаёт релизы свежими вперёд, но полагаться на это не станем. sort.Slice(notes, func(i, j int) bool { if IsNewer(notes[j].Version, notes[i].Version) { return true } if IsNewer(notes[i].Version, notes[j].Version) { return false } return notes[i].Published > notes[j].Published }) latest := notes[0] info.Latest = latest.Version info.Notes = latest.Notes info.URL = latest.URL info.Published = latest.Published info.Available = IsNewer(u.Version, latest.Version) info.Changelog = NewerThan(notes, u.Version) return info } // NewerThan оставляет из списка релизы новее указанной версии, сохраняя // порядок «свежие первыми». func NewerThan(notes []ReleaseNote, current string) []ReleaseNote { out := make([]ReleaseNote, 0, len(notes)) for _, note := range notes { if IsNewer(current, note.Version) { out = append(out, note) } } return out } // 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 }