feat: add managed installation and system updates
This commit is contained in:
@@ -0,0 +1,282 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
BuildVersion = "dev"
|
||||
BuildCommit = ""
|
||||
BuildDate = ""
|
||||
)
|
||||
|
||||
type systemVersionInfo struct {
|
||||
CurrentVersion string `json:"currentVersion"`
|
||||
CurrentCommit string `json:"currentCommit,omitempty"`
|
||||
BuildDate string `json:"buildDate,omitempty"`
|
||||
LatestVersion string `json:"latestVersion,omitempty"`
|
||||
LatestName string `json:"latestName,omitempty"`
|
||||
ReleaseURL string `json:"releaseUrl,omitempty"`
|
||||
ReleaseNotes string `json:"releaseNotes,omitempty"`
|
||||
PublishedAt *time.Time `json:"publishedAt,omitempty"`
|
||||
UpdateAvailable bool `json:"updateAvailable"`
|
||||
UpdateEnabled bool `json:"updateEnabled"`
|
||||
CheckError string `json:"checkError,omitempty"`
|
||||
}
|
||||
|
||||
type githubRelease struct {
|
||||
TagName string `json:"tag_name"`
|
||||
Name string `json:"name"`
|
||||
HTMLURL string `json:"html_url"`
|
||||
Body string `json:"body"`
|
||||
PublishedAt time.Time `json:"published_at"`
|
||||
}
|
||||
|
||||
func (a *App) handleSystemVersion(w http.ResponseWriter, r *http.Request) {
|
||||
info, err := a.systemVersion(r.Context())
|
||||
if err != nil {
|
||||
info.CheckError = "暂时无法连接版本服务"
|
||||
a.log.Warn("check system version", "error", err)
|
||||
}
|
||||
respondJSON(w, http.StatusOK, info)
|
||||
}
|
||||
|
||||
func (a *App) handleSystemUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
user := currentUser(r)
|
||||
if user == nil || user.Role != "admin" {
|
||||
respondError(w, http.StatusForbidden, "system administrator required")
|
||||
return
|
||||
}
|
||||
if !a.updateEnabled() {
|
||||
respondError(w, http.StatusServiceUnavailable, "online update is not configured")
|
||||
return
|
||||
}
|
||||
|
||||
info, err := a.systemVersion(r.Context())
|
||||
if err != nil {
|
||||
respondError(w, http.StatusBadGateway, "failed to check latest release")
|
||||
return
|
||||
}
|
||||
if !info.UpdateAvailable {
|
||||
respondError(w, http.StatusConflict, "already on the latest version")
|
||||
return
|
||||
}
|
||||
|
||||
backupPath, err := a.backupDatabaseBeforeUpdate(r.Context())
|
||||
if err != nil {
|
||||
a.log.Error("backup database before update", "error", err)
|
||||
respondError(w, http.StatusInternalServerError, "failed to back up database")
|
||||
return
|
||||
}
|
||||
if err := a.triggerUpdateService(r.Context()); err != nil {
|
||||
a.log.Error("trigger system update", "error", err)
|
||||
respondError(w, http.StatusBadGateway, "failed to start update")
|
||||
return
|
||||
}
|
||||
|
||||
a.log.Info("system update requested", "user", user.ID, "from", info.CurrentVersion, "to", info.LatestVersion, "backup", backupPath)
|
||||
respondJSON(w, http.StatusAccepted, map[string]any{
|
||||
"ok": true,
|
||||
"currentVersion": info.CurrentVersion,
|
||||
"targetVersion": info.LatestVersion,
|
||||
"message": "更新已启动,服务会在完成后自动恢复",
|
||||
})
|
||||
}
|
||||
|
||||
func (a *App) systemVersion(ctx context.Context) (systemVersionInfo, error) {
|
||||
current := strings.TrimSpace(a.cfg.AppVersion)
|
||||
if current == "" {
|
||||
current = BuildVersion
|
||||
}
|
||||
info := systemVersionInfo{
|
||||
CurrentVersion: current,
|
||||
CurrentCommit: strings.TrimSpace(BuildCommit),
|
||||
BuildDate: strings.TrimSpace(BuildDate),
|
||||
UpdateEnabled: a.updateEnabled(),
|
||||
}
|
||||
|
||||
release, err := a.fetchLatestRelease(ctx)
|
||||
if err != nil {
|
||||
return info, err
|
||||
}
|
||||
info.LatestVersion = strings.TrimSpace(release.TagName)
|
||||
info.LatestName = strings.TrimSpace(release.Name)
|
||||
info.ReleaseURL = strings.TrimSpace(release.HTMLURL)
|
||||
info.ReleaseNotes = strings.TrimSpace(release.Body)
|
||||
if !release.PublishedAt.IsZero() {
|
||||
info.PublishedAt = &release.PublishedAt
|
||||
}
|
||||
info.UpdateAvailable = versionIsNewer(info.LatestVersion, info.CurrentVersion)
|
||||
return info, nil
|
||||
}
|
||||
|
||||
func (a *App) fetchLatestRelease(ctx context.Context) (githubRelease, error) {
|
||||
endpoint := strings.TrimSpace(a.cfg.ReleaseAPIURL)
|
||||
parsed, err := url.Parse(endpoint)
|
||||
if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") {
|
||||
return githubRelease{}, errors.New("invalid release API URL")
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, parsed.String(), nil)
|
||||
if err != nil {
|
||||
return githubRelease{}, err
|
||||
}
|
||||
req.Header.Set("Accept", "application/vnd.github+json")
|
||||
req.Header.Set("User-Agent", "NewSzxcn-Email/"+strings.TrimPrefix(a.cfg.AppVersion, "v"))
|
||||
client := &http.Client{
|
||||
Timeout: 8 * time.Second,
|
||||
CheckRedirect: func(*http.Request, []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
},
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return githubRelease{}, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 4096))
|
||||
return githubRelease{}, fmt.Errorf("release API returned %s", resp.Status)
|
||||
}
|
||||
var release githubRelease
|
||||
if err := json.NewDecoder(io.LimitReader(resp.Body, 2<<20)).Decode(&release); err != nil {
|
||||
return githubRelease{}, err
|
||||
}
|
||||
if strings.TrimSpace(release.TagName) == "" {
|
||||
return githubRelease{}, errors.New("release API returned an empty tag")
|
||||
}
|
||||
return release, nil
|
||||
}
|
||||
|
||||
func (a *App) updateEnabled() bool {
|
||||
return strings.TrimSpace(a.cfg.UpdateServiceURL) != "" && strings.TrimSpace(a.cfg.UpdateServiceToken) != ""
|
||||
}
|
||||
|
||||
func (a *App) triggerUpdateService(ctx context.Context) error {
|
||||
parsed, err := url.Parse(strings.TrimSpace(a.cfg.UpdateServiceURL))
|
||||
if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") {
|
||||
return errors.New("invalid update service URL")
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, parsed.String(), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+strings.TrimSpace(a.cfg.UpdateServiceToken))
|
||||
client := &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
CheckRedirect: func(*http.Request, []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
},
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 64<<10))
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("update service returned %s", resp.Status)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) backupDatabaseBeforeUpdate(ctx context.Context) (string, error) {
|
||||
backupDir := filepath.Join(a.cfg.DataDir, "backups")
|
||||
if err := os.MkdirAll(backupDir, 0o700); err != nil {
|
||||
return "", err
|
||||
}
|
||||
backupPath := filepath.Join(backupDir, "pre-update-"+a.now().UTC().Format("20060102T150405.000000000Z")+".db")
|
||||
quotedPath := strings.ReplaceAll(backupPath, "'", "''")
|
||||
if _, err := a.db.ExecContext(ctx, "VACUUM INTO '"+quotedPath+"'"); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := pruneUpdateBackups(backupDir, 5); err != nil {
|
||||
a.log.Warn("prune update backups", "error", err)
|
||||
}
|
||||
return backupPath, nil
|
||||
}
|
||||
|
||||
func pruneUpdateBackups(dir string, keep int) error {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
type backupFile struct {
|
||||
path string
|
||||
modTime time.Time
|
||||
}
|
||||
backups := make([]backupFile, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || !strings.HasPrefix(entry.Name(), "pre-update-") || !strings.HasSuffix(entry.Name(), ".db") {
|
||||
continue
|
||||
}
|
||||
info, err := entry.Info()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
backups = append(backups, backupFile{path: filepath.Join(dir, entry.Name()), modTime: info.ModTime()})
|
||||
}
|
||||
sort.Slice(backups, func(i, j int) bool { return backups[i].modTime.After(backups[j].modTime) })
|
||||
if keep < 0 {
|
||||
keep = 0
|
||||
}
|
||||
if len(backups) <= keep {
|
||||
return nil
|
||||
}
|
||||
for _, backup := range backups[keep:] {
|
||||
if err := os.Remove(backup.path); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var versionPattern = regexp.MustCompile(`^[vV]?(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$`)
|
||||
|
||||
func versionIsNewer(latest, current string) bool {
|
||||
latestParts, latestPrerelease, latestOK := parseVersion(latest)
|
||||
currentParts, currentPrerelease, currentOK := parseVersion(current)
|
||||
if !latestOK {
|
||||
return false
|
||||
}
|
||||
if !currentOK {
|
||||
return true
|
||||
}
|
||||
for i := 0; i < len(latestParts); i++ {
|
||||
if latestParts[i] != currentParts[i] {
|
||||
return latestParts[i] > currentParts[i]
|
||||
}
|
||||
}
|
||||
return currentPrerelease != "" && latestPrerelease == ""
|
||||
}
|
||||
|
||||
func parseVersion(value string) ([3]int, string, bool) {
|
||||
match := versionPattern.FindStringSubmatch(strings.TrimSpace(value))
|
||||
if match == nil {
|
||||
return [3]int{}, "", false
|
||||
}
|
||||
var parts [3]int
|
||||
for i := 0; i < 3; i++ {
|
||||
if match[i+1] == "" {
|
||||
continue
|
||||
}
|
||||
part, err := strconv.Atoi(match[i+1])
|
||||
if err != nil {
|
||||
return [3]int{}, "", false
|
||||
}
|
||||
parts[i] = part
|
||||
}
|
||||
return parts, match[4], true
|
||||
}
|
||||
Reference in New Issue
Block a user