mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-09-24 16:05:44 +08:00
Merge pull request #3881 from Wei-Shaw/feat/version-rollback
feat: 版本徽章新增近3个历史版本在线回退与手动回退指引
This commit is contained in:
@@ -26,6 +26,8 @@ type systemUpdateService interface {
|
||||
CheckUpdate(ctx context.Context, force bool) (*service.UpdateInfo, error)
|
||||
PerformUpdate(ctx context.Context) error
|
||||
Rollback() error
|
||||
ListRollbackVersions(ctx context.Context) ([]service.RollbackVersion, error)
|
||||
RollbackToVersion(ctx context.Context, version string) error
|
||||
}
|
||||
|
||||
// NewSystemHandler creates a new SystemHandler
|
||||
@@ -102,11 +104,42 @@ func (h *SystemHandler) PerformUpdate(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// Rollback restores the previous version
|
||||
// GetRollbackVersions lists versions available for rollback
|
||||
// GET /api/v1/admin/system/rollback-versions
|
||||
func (h *SystemHandler) GetRollbackVersions(c *gin.Context) {
|
||||
versions, err := h.updateSvc.ListRollbackVersions(c.Request.Context())
|
||||
if err != nil {
|
||||
response.Error(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
response.Success(c, gin.H{
|
||||
"versions": versions,
|
||||
})
|
||||
}
|
||||
|
||||
// Rollback restores a previous version.
|
||||
// Without a body (or with an empty version) it restores the local .backup binary
|
||||
// left by the last in-place update. With {"version": "x.y.z"} it downloads and
|
||||
// installs that specific release (must be one of the recent rollback versions).
|
||||
// POST /api/v1/admin/system/rollback
|
||||
func (h *SystemHandler) Rollback(c *gin.Context) {
|
||||
operationID := buildSystemOperationID(c, "rollback")
|
||||
payload := gin.H{"operation_id": operationID}
|
||||
var req struct {
|
||||
Version string `json:"version"`
|
||||
}
|
||||
if c.Request.Body != nil && c.Request.ContentLength > 0 {
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.Error(c, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
}
|
||||
targetVersion := strings.TrimSpace(req.Version)
|
||||
|
||||
operation := "rollback"
|
||||
if targetVersion != "" {
|
||||
operation = "rollback:" + targetVersion
|
||||
}
|
||||
operationID := buildSystemOperationID(c, operation)
|
||||
payload := gin.H{"operation_id": operationID, "version": targetVersion}
|
||||
executeAdminIdempotentJSON(c, "admin.system.rollback", payload, service.DefaultSystemOperationIdempotencyTTL(), func(ctx context.Context) (any, error) {
|
||||
lock, release, err := h.acquireSystemLock(ctx, operationID)
|
||||
if err != nil {
|
||||
@@ -118,7 +151,12 @@ func (h *SystemHandler) Rollback(c *gin.Context) {
|
||||
release(releaseReason, succeeded)
|
||||
}()
|
||||
|
||||
if err := h.updateSvc.Rollback(); err != nil {
|
||||
if targetVersion != "" {
|
||||
err = h.updateSvc.RollbackToVersion(ctx, targetVersion)
|
||||
} else {
|
||||
err = h.updateSvc.Rollback()
|
||||
}
|
||||
if err != nil {
|
||||
releaseReason = "SYSTEM_ROLLBACK_FAILED"
|
||||
return nil, err
|
||||
}
|
||||
@@ -127,6 +165,7 @@ func (h *SystemHandler) Rollback(c *gin.Context) {
|
||||
return gin.H{
|
||||
"message": "Rollback completed. Please restart the service.",
|
||||
"need_restart": true,
|
||||
"version": targetVersion,
|
||||
"operation_id": lock.OperationID(),
|
||||
}, nil
|
||||
})
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -17,11 +18,18 @@ import (
|
||||
)
|
||||
|
||||
type systemHandlerUpdateServiceStub struct {
|
||||
performErr error
|
||||
updateInfo *service.UpdateInfo
|
||||
checkErr error
|
||||
checkForces []bool
|
||||
performCall int
|
||||
performErr error
|
||||
updateInfo *service.UpdateInfo
|
||||
checkErr error
|
||||
checkForces []bool
|
||||
performCall int
|
||||
rollbackCall int
|
||||
rollbackToCall int
|
||||
rollbackToVersions []string
|
||||
rollbackToErr error
|
||||
rollbackVersions []service.RollbackVersion
|
||||
rollbackVersionsErr error
|
||||
rollbackVersionsCall int
|
||||
}
|
||||
|
||||
func (s *systemHandlerUpdateServiceStub) CheckUpdate(_ context.Context, force bool) (*service.UpdateInfo, error) {
|
||||
@@ -35,9 +43,21 @@ func (s *systemHandlerUpdateServiceStub) PerformUpdate(context.Context) error {
|
||||
}
|
||||
|
||||
func (s *systemHandlerUpdateServiceStub) Rollback() error {
|
||||
s.rollbackCall++
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *systemHandlerUpdateServiceStub) ListRollbackVersions(context.Context) ([]service.RollbackVersion, error) {
|
||||
s.rollbackVersionsCall++
|
||||
return s.rollbackVersions, s.rollbackVersionsErr
|
||||
}
|
||||
|
||||
func (s *systemHandlerUpdateServiceStub) RollbackToVersion(_ context.Context, version string) error {
|
||||
s.rollbackToCall++
|
||||
s.rollbackToVersions = append(s.rollbackToVersions, version)
|
||||
return s.rollbackToErr
|
||||
}
|
||||
|
||||
type systemUpdateResponseEnvelope struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
@@ -71,6 +91,8 @@ func newSystemHandlerTestRouter(t *testing.T, updateSvc *systemHandlerUpdateServ
|
||||
|
||||
router := gin.New()
|
||||
router.POST("/api/v1/admin/system/update", handler.PerformUpdate)
|
||||
router.POST("/api/v1/admin/system/rollback", handler.Rollback)
|
||||
router.GET("/api/v1/admin/system/rollback-versions", handler.GetRollbackVersions)
|
||||
return router
|
||||
}
|
||||
|
||||
@@ -142,3 +164,104 @@ func TestSystemHandlerPerformUpdateFailureStillReturnsInternalError(t *testing.T
|
||||
require.Equal(t, http.StatusInternalServerError, body.Code)
|
||||
require.Equal(t, "internal error", body.Message)
|
||||
}
|
||||
|
||||
func TestSystemHandlerRollbackWithoutBodyUsesLegacyBackup(t *testing.T) {
|
||||
updateSvc := &systemHandlerUpdateServiceStub{}
|
||||
repo := newMemoryIdempotencyRepoStub()
|
||||
router := newSystemHandlerTestRouter(t, updateSvc, repo)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/system/rollback", nil)
|
||||
req.Header.Set("Idempotency-Key", "legacy-rollback")
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
require.Equal(t, 1, updateSvc.rollbackCall)
|
||||
require.Equal(t, 0, updateSvc.rollbackToCall)
|
||||
requireSystemLockStatus(t, repo, service.IdempotencyStatusSucceeded)
|
||||
}
|
||||
|
||||
func TestSystemHandlerRollbackWithVersionCallsRollbackToVersion(t *testing.T) {
|
||||
updateSvc := &systemHandlerUpdateServiceStub{}
|
||||
repo := newMemoryIdempotencyRepoStub()
|
||||
router := newSystemHandlerTestRouter(t, updateSvc, repo)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/system/rollback",
|
||||
strings.NewReader(`{"version":"0.1.146"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Idempotency-Key", "rollback-to-146")
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
require.Equal(t, 0, updateSvc.rollbackCall)
|
||||
require.Equal(t, 1, updateSvc.rollbackToCall)
|
||||
require.Equal(t, []string{"0.1.146"}, updateSvc.rollbackToVersions)
|
||||
requireSystemLockStatus(t, repo, service.IdempotencyStatusSucceeded)
|
||||
|
||||
var body systemUpdateResponseEnvelope
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &body))
|
||||
require.Equal(t, 0, body.Code)
|
||||
require.Equal(t, "Rollback completed. Please restart the service.", body.Data.Message)
|
||||
}
|
||||
|
||||
func TestSystemHandlerRollbackWithDisallowedVersionReturnsBadRequest(t *testing.T) {
|
||||
updateSvc := &systemHandlerUpdateServiceStub{
|
||||
rollbackToErr: service.ErrRollbackVersionNotAllowed,
|
||||
}
|
||||
repo := newMemoryIdempotencyRepoStub()
|
||||
router := newSystemHandlerTestRouter(t, updateSvc, repo)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/system/rollback",
|
||||
strings.NewReader(`{"version":"9.9.9"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Idempotency-Key", "rollback-to-bad")
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
require.Equal(t, http.StatusBadRequest, rec.Code)
|
||||
require.Equal(t, 1, updateSvc.rollbackToCall)
|
||||
}
|
||||
|
||||
func TestSystemHandlerGetRollbackVersions(t *testing.T) {
|
||||
updateSvc := &systemHandlerUpdateServiceStub{
|
||||
rollbackVersions: []service.RollbackVersion{
|
||||
{Version: "0.1.146", PublishedAt: "2026-07-07T00:00:00Z", HTMLURL: "https://example.com/v0.1.146"},
|
||||
{Version: "0.1.145", PublishedAt: "2026-07-06T00:00:00Z", HTMLURL: "https://example.com/v0.1.145"},
|
||||
},
|
||||
}
|
||||
repo := newMemoryIdempotencyRepoStub()
|
||||
router := newSystemHandlerTestRouter(t, updateSvc, repo)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/system/rollback-versions", nil)
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
require.Equal(t, 1, updateSvc.rollbackVersionsCall)
|
||||
|
||||
var body struct {
|
||||
Code int `json:"code"`
|
||||
Data struct {
|
||||
Versions []service.RollbackVersion `json:"versions"`
|
||||
} `json:"data"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &body))
|
||||
require.Equal(t, 0, body.Code)
|
||||
require.Len(t, body.Data.Versions, 2)
|
||||
require.Equal(t, "0.1.146", body.Data.Versions[0].Version)
|
||||
}
|
||||
|
||||
func TestSystemHandlerGetRollbackVersionsError(t *testing.T) {
|
||||
updateSvc := &systemHandlerUpdateServiceStub{
|
||||
rollbackVersionsErr: errors.New("github unavailable"),
|
||||
}
|
||||
repo := newMemoryIdempotencyRepoStub()
|
||||
router := newSystemHandlerTestRouter(t, updateSvc, repo)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/system/rollback-versions", nil)
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
require.Equal(t, http.StatusInternalServerError, rec.Code)
|
||||
}
|
||||
|
||||
@@ -67,6 +67,10 @@ func (c *githubReleaseClientError) FetchLatestRelease(ctx context.Context, repo
|
||||
return nil, c.err
|
||||
}
|
||||
|
||||
func (c *githubReleaseClientError) FetchRecentReleases(ctx context.Context, repo string, perPage int) ([]*service.GitHubRelease, error) {
|
||||
return nil, c.err
|
||||
}
|
||||
|
||||
func (c *githubReleaseClientError) DownloadFile(ctx context.Context, url, dest string, maxSize int64) error {
|
||||
return c.err
|
||||
}
|
||||
@@ -103,6 +107,40 @@ func (c *githubReleaseClient) FetchLatestRelease(ctx context.Context, repo strin
|
||||
return &release, nil
|
||||
}
|
||||
|
||||
func (c *githubReleaseClient) FetchRecentReleases(ctx context.Context, repo string, perPage int) ([]*service.GitHubRelease, error) {
|
||||
if perPage <= 0 {
|
||||
perPage = 10
|
||||
}
|
||||
if perPage > 100 {
|
||||
perPage = 100 // GitHub API hard limit
|
||||
}
|
||||
url := fmt.Sprintf("https://api.github.com/repos/%s/releases?per_page=%d", repo, perPage)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Accept", "application/vnd.github.v3+json")
|
||||
req.Header.Set("User-Agent", "Sub2API-Updater")
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("GitHub API returned %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var releases []*service.GitHubRelease
|
||||
if err := json.NewDecoder(resp.Body).Decode(&releases); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return releases, nil
|
||||
}
|
||||
|
||||
func (c *githubReleaseClient) DownloadFile(ctx context.Context, url, dest string, maxSize int64) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
|
||||
@@ -29,6 +29,9 @@ type testTransport struct {
|
||||
func (t *testTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
// Rewrite the URL to point to our test server
|
||||
testURL := t.testServerURL + req.URL.Path
|
||||
if req.URL.RawQuery != "" {
|
||||
testURL += "?" + req.URL.RawQuery
|
||||
}
|
||||
newReq, err := http.NewRequestWithContext(req.Context(), req.Method, testURL, req.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -245,6 +248,76 @@ func (s *GitHubReleaseServiceSuite) TestFetchLatestRelease_Success() {
|
||||
require.Equal(s.T(), "app-linux-amd64.tar.gz", release.Assets[0].Name)
|
||||
}
|
||||
|
||||
func (s *GitHubReleaseServiceSuite) TestFetchRecentReleases_Success() {
|
||||
releasesJSON := `[
|
||||
{
|
||||
"tag_name": "v1.0.1",
|
||||
"name": "Release 1.0.1",
|
||||
"html_url": "https://github.com/test/repo/releases/v1.0.1",
|
||||
"published_at": "2026-07-08T00:00:00Z",
|
||||
"prerelease": false,
|
||||
"assets": [
|
||||
{
|
||||
"name": "app-linux-amd64.tar.gz",
|
||||
"browser_download_url": "https://github.com/test/repo/releases/download/v1.0.1/app-linux-amd64.tar.gz"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tag_name": "v1.0.1-rc1",
|
||||
"name": "Release 1.0.1-rc1",
|
||||
"prerelease": true
|
||||
},
|
||||
{
|
||||
"tag_name": "v1.0.0",
|
||||
"name": "Release 1.0.0"
|
||||
}
|
||||
]`
|
||||
|
||||
s.srv = newLocalTestServer(s.T(), http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
require.Equal(s.T(), "/repos/test/repo/releases", r.URL.Path)
|
||||
require.Equal(s.T(), "15", r.URL.Query().Get("per_page"))
|
||||
require.Equal(s.T(), "application/vnd.github.v3+json", r.Header.Get("Accept"))
|
||||
require.Equal(s.T(), "Sub2API-Updater", r.Header.Get("User-Agent"))
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(releasesJSON))
|
||||
}))
|
||||
|
||||
s.client = &githubReleaseClient{
|
||||
httpClient: &http.Client{
|
||||
Transport: &testTransport{testServerURL: s.srv.URL},
|
||||
},
|
||||
downloadHTTPClient: &http.Client{},
|
||||
}
|
||||
|
||||
releases, err := s.client.FetchRecentReleases(context.Background(), "test/repo", 15)
|
||||
require.NoError(s.T(), err)
|
||||
require.Len(s.T(), releases, 3)
|
||||
require.Equal(s.T(), "v1.0.1", releases[0].TagName)
|
||||
require.False(s.T(), releases[0].Prerelease)
|
||||
require.Len(s.T(), releases[0].Assets, 1)
|
||||
require.True(s.T(), releases[1].Prerelease)
|
||||
require.Equal(s.T(), "v1.0.0", releases[2].TagName)
|
||||
}
|
||||
|
||||
func (s *GitHubReleaseServiceSuite) TestFetchRecentReleases_Non200() {
|
||||
s.srv = newLocalTestServer(s.T(), http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
}))
|
||||
|
||||
s.client = &githubReleaseClient{
|
||||
httpClient: &http.Client{
|
||||
Transport: &testTransport{testServerURL: s.srv.URL},
|
||||
},
|
||||
downloadHTTPClient: &http.Client{},
|
||||
}
|
||||
|
||||
_, err := s.client.FetchRecentReleases(context.Background(), "test/repo", 15)
|
||||
require.Error(s.T(), err)
|
||||
require.Contains(s.T(), err.Error(), "403")
|
||||
}
|
||||
|
||||
func (s *GitHubReleaseServiceSuite) TestFetchLatestRelease_Non200() {
|
||||
s.srv = newLocalTestServer(s.T(), http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
|
||||
@@ -545,6 +545,7 @@ func registerSystemRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
|
||||
{
|
||||
system.GET("/version", h.Admin.System.GetVersion)
|
||||
system.GET("/check-updates", h.Admin.System.CheckUpdates)
|
||||
system.GET("/rollback-versions", h.Admin.System.GetRollbackVersions)
|
||||
system.POST("/update", h.Admin.System.PerformUpdate)
|
||||
system.POST("/rollback", h.Admin.System.Rollback)
|
||||
system.POST("/restart", h.Admin.System.RestartService)
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -22,7 +23,8 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
ErrNoUpdateAvailable = infraerrors.Conflict("ALREADY_UP_TO_DATE", "no update available; current version is latest")
|
||||
ErrNoUpdateAvailable = infraerrors.Conflict("ALREADY_UP_TO_DATE", "no update available; current version is latest")
|
||||
ErrRollbackVersionNotAllowed = infraerrors.BadRequest("ROLLBACK_VERSION_NOT_ALLOWED", "version is not in the allowed rollback list")
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -36,6 +38,11 @@ const (
|
||||
|
||||
// Security: max download size (500MB)
|
||||
maxDownloadSize = 500 * 1024 * 1024
|
||||
|
||||
// Rollback: expose at most the 3 most recent versions older than current
|
||||
maxRollbackVersions = 3
|
||||
// Fetch a few extra releases so filtering (current/newer/prerelease) still leaves enough candidates
|
||||
rollbackFetchPageSize = 15
|
||||
)
|
||||
|
||||
// UpdateCache defines cache operations for update service
|
||||
@@ -47,6 +54,7 @@ type UpdateCache interface {
|
||||
// GitHubReleaseClient 获取 GitHub release 信息的接口
|
||||
type GitHubReleaseClient interface {
|
||||
FetchLatestRelease(ctx context.Context, repo string) (*GitHubRelease, error)
|
||||
FetchRecentReleases(ctx context.Context, repo string, perPage int) ([]*GitHubRelease, error)
|
||||
DownloadFile(ctx context.Context, url, dest string, maxSize int64) error
|
||||
FetchChecksumFile(ctx context.Context, url string) ([]byte, error)
|
||||
}
|
||||
@@ -103,9 +111,18 @@ type GitHubRelease struct {
|
||||
Body string `json:"body"`
|
||||
PublishedAt string `json:"published_at"`
|
||||
HTMLURL string `json:"html_url"`
|
||||
Draft bool `json:"draft"`
|
||||
Prerelease bool `json:"prerelease"`
|
||||
Assets []GitHubAsset `json:"assets"`
|
||||
}
|
||||
|
||||
// RollbackVersion describes a release version the system can roll back to
|
||||
type RollbackVersion struct {
|
||||
Version string `json:"version"` // without "v" prefix, e.g. "0.1.146"
|
||||
PublishedAt string `json:"published_at"`
|
||||
HTMLURL string `json:"html_url"`
|
||||
}
|
||||
|
||||
type GitHubAsset struct {
|
||||
Name string `json:"name"`
|
||||
BrowserDownloadURL string `json:"browser_download_url"`
|
||||
@@ -155,12 +172,19 @@ func (s *UpdateService) PerformUpdate(ctx context.Context) error {
|
||||
return ErrNoUpdateAvailable
|
||||
}
|
||||
|
||||
return s.applyReleaseAssets(ctx, info.ReleaseInfo.Assets)
|
||||
}
|
||||
|
||||
// applyReleaseAssets downloads the platform archive from the given release assets,
|
||||
// verifies its checksum, and atomically swaps the running binary.
|
||||
// Shared by PerformUpdate (latest) and RollbackToVersion (specific older version).
|
||||
func (s *UpdateService) applyReleaseAssets(ctx context.Context, releaseAssets []Asset) error {
|
||||
// Find matching archive and checksum for current platform
|
||||
archiveName := s.getArchiveName()
|
||||
var downloadURL string
|
||||
var checksumURL string
|
||||
|
||||
for _, asset := range info.ReleaseInfo.Assets {
|
||||
for _, asset := range releaseAssets {
|
||||
if strings.Contains(asset.Name, archiveName) && !strings.HasSuffix(asset.Name, ".txt") {
|
||||
downloadURL = asset.DownloadURL
|
||||
}
|
||||
@@ -279,6 +303,102 @@ func (s *UpdateService) Rollback() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListRollbackVersions returns up to maxRollbackVersions release versions that are
|
||||
// strictly older than the current version (the current version itself is excluded),
|
||||
// newest first. Draft and prerelease entries are skipped.
|
||||
func (s *UpdateService) ListRollbackVersions(ctx context.Context) ([]RollbackVersion, error) {
|
||||
releases, err := s.fetchRollbackCandidates(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
versions := make([]RollbackVersion, 0, len(releases))
|
||||
for _, r := range releases {
|
||||
versions = append(versions, RollbackVersion{
|
||||
Version: strings.TrimPrefix(r.TagName, "v"),
|
||||
PublishedAt: r.PublishedAt,
|
||||
HTMLURL: r.HTMLURL,
|
||||
})
|
||||
}
|
||||
return versions, nil
|
||||
}
|
||||
|
||||
// RollbackToVersion downloads and installs a specific older version.
|
||||
// The target must be one of the versions returned by ListRollbackVersions;
|
||||
// anything else (including the current version) is rejected.
|
||||
func (s *UpdateService) RollbackToVersion(ctx context.Context, version string) error {
|
||||
target := strings.TrimPrefix(strings.TrimSpace(version), "v")
|
||||
if target == "" {
|
||||
return ErrRollbackVersionNotAllowed
|
||||
}
|
||||
|
||||
releases, err := s.fetchRollbackCandidates(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var match *GitHubRelease
|
||||
for _, r := range releases {
|
||||
if strings.TrimPrefix(r.TagName, "v") == target {
|
||||
match = r
|
||||
break
|
||||
}
|
||||
}
|
||||
if match == nil {
|
||||
return ErrRollbackVersionNotAllowed
|
||||
}
|
||||
|
||||
assets := make([]Asset, len(match.Assets))
|
||||
for i, a := range match.Assets {
|
||||
assets[i] = Asset{
|
||||
Name: a.Name,
|
||||
DownloadURL: a.BrowserDownloadURL,
|
||||
Size: a.Size,
|
||||
}
|
||||
}
|
||||
|
||||
return s.applyReleaseAssets(ctx, assets)
|
||||
}
|
||||
|
||||
// fetchRollbackCandidates fetches recent releases and keeps the newest
|
||||
// maxRollbackVersions entries strictly older than the current version.
|
||||
func (s *UpdateService) fetchRollbackCandidates(ctx context.Context) ([]*GitHubRelease, error) {
|
||||
releases, err := s.githubClient.FetchRecentReleases(ctx, githubRepo, rollbackFetchPageSize)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
seen := make(map[string]bool, len(releases))
|
||||
candidates := make([]*GitHubRelease, 0, maxRollbackVersions)
|
||||
for _, r := range releases {
|
||||
if r == nil || r.Draft || r.Prerelease {
|
||||
continue
|
||||
}
|
||||
v := strings.TrimPrefix(r.TagName, "v")
|
||||
if v == "" || seen[v] {
|
||||
continue
|
||||
}
|
||||
// Only versions strictly older than current (also excludes current itself)
|
||||
if compareVersions(v, s.currentVersion) >= 0 {
|
||||
continue
|
||||
}
|
||||
seen[v] = true
|
||||
candidates = append(candidates, r)
|
||||
}
|
||||
|
||||
sort.SliceStable(candidates, func(i, j int) bool {
|
||||
return compareVersions(
|
||||
strings.TrimPrefix(candidates[i].TagName, "v"),
|
||||
strings.TrimPrefix(candidates[j].TagName, "v"),
|
||||
) > 0
|
||||
})
|
||||
|
||||
if len(candidates) > maxRollbackVersions {
|
||||
candidates = candidates[:maxRollbackVersions]
|
||||
}
|
||||
return candidates, nil
|
||||
}
|
||||
|
||||
func (s *UpdateService) fetchLatestRelease(ctx context.Context) (*UpdateInfo, error) {
|
||||
release, err := s.githubClient.FetchLatestRelease(ctx, githubRepo)
|
||||
if err != nil {
|
||||
|
||||
@@ -28,13 +28,19 @@ func (s *updateServiceCacheStub) SetUpdateInfo(_ context.Context, data string, _
|
||||
}
|
||||
|
||||
type updateServiceGitHubClientStub struct {
|
||||
release *GitHubRelease
|
||||
release *GitHubRelease
|
||||
recentReleases []*GitHubRelease
|
||||
recentErr error
|
||||
}
|
||||
|
||||
func (s *updateServiceGitHubClientStub) FetchLatestRelease(context.Context, string) (*GitHubRelease, error) {
|
||||
return s.release, nil
|
||||
}
|
||||
|
||||
func (s *updateServiceGitHubClientStub) FetchRecentReleases(context.Context, string, int) ([]*GitHubRelease, error) {
|
||||
return s.recentReleases, s.recentErr
|
||||
}
|
||||
|
||||
func (s *updateServiceGitHubClientStub) DownloadFile(context.Context, string, string, int64) error {
|
||||
panic("DownloadFile should not be called when no update is available")
|
||||
}
|
||||
@@ -62,3 +68,120 @@ func TestUpdateServicePerformUpdateNoUpdateReturnsSentinel(t *testing.T) {
|
||||
require.True(t, errors.Is(err, ErrNoUpdateAvailable))
|
||||
require.ErrorIs(t, err, ErrNoUpdateAvailable)
|
||||
}
|
||||
|
||||
func newRollbackTestService(current string, releases []*GitHubRelease) *UpdateService {
|
||||
return NewUpdateService(
|
||||
&updateServiceCacheStub{},
|
||||
&updateServiceGitHubClientStub{recentReleases: releases},
|
||||
current,
|
||||
"release",
|
||||
)
|
||||
}
|
||||
|
||||
func TestUpdateServiceListRollbackVersionsFiltersAndCaps(t *testing.T) {
|
||||
releases := []*GitHubRelease{
|
||||
{TagName: "v0.1.148", PublishedAt: "2026-07-09T00:00:00Z"}, // newer than current: excluded
|
||||
{TagName: "v0.1.147", PublishedAt: "2026-07-08T00:00:00Z"}, // current: excluded
|
||||
{TagName: "v0.1.146-rc1", PublishedAt: "2026-07-07T12:00:00Z", Prerelease: true}, // prerelease: excluded
|
||||
{TagName: "v0.1.146", PublishedAt: "2026-07-07T00:00:00Z"},
|
||||
{TagName: "v0.1.145", PublishedAt: "2026-07-06T00:00:00Z", Draft: true}, // draft: excluded
|
||||
{TagName: "v0.1.144", PublishedAt: "2026-07-05T00:00:00Z"},
|
||||
{TagName: "v0.1.144", PublishedAt: "2026-07-05T00:00:00Z"}, // duplicate: excluded
|
||||
{TagName: "v0.1.143", PublishedAt: "2026-07-04T00:00:00Z"},
|
||||
{TagName: "v0.1.142", PublishedAt: "2026-07-03T00:00:00Z"}, // beyond cap of 3: excluded
|
||||
}
|
||||
svc := newRollbackTestService("0.1.147", releases)
|
||||
|
||||
versions, err := svc.ListRollbackVersions(context.Background())
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Len(t, versions, 3)
|
||||
require.Equal(t, "0.1.146", versions[0].Version)
|
||||
require.Equal(t, "0.1.144", versions[1].Version)
|
||||
require.Equal(t, "0.1.143", versions[2].Version)
|
||||
}
|
||||
|
||||
func TestUpdateServiceListRollbackVersionsSortsUnorderedInput(t *testing.T) {
|
||||
releases := []*GitHubRelease{
|
||||
{TagName: "v0.1.144"},
|
||||
{TagName: "v0.1.146"},
|
||||
{TagName: "v0.1.145"},
|
||||
}
|
||||
svc := newRollbackTestService("0.1.147", releases)
|
||||
|
||||
versions, err := svc.ListRollbackVersions(context.Background())
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Len(t, versions, 3)
|
||||
require.Equal(t, "0.1.146", versions[0].Version)
|
||||
require.Equal(t, "0.1.145", versions[1].Version)
|
||||
require.Equal(t, "0.1.144", versions[2].Version)
|
||||
}
|
||||
|
||||
func TestUpdateServiceListRollbackVersionsEmptyWhenNoneOlder(t *testing.T) {
|
||||
releases := []*GitHubRelease{
|
||||
{TagName: "v0.1.147"},
|
||||
{TagName: "v0.1.148"},
|
||||
}
|
||||
svc := newRollbackTestService("0.1.147", releases)
|
||||
|
||||
versions, err := svc.ListRollbackVersions(context.Background())
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, versions)
|
||||
}
|
||||
|
||||
func TestUpdateServiceListRollbackVersionsPropagatesFetchError(t *testing.T) {
|
||||
svc := NewUpdateService(
|
||||
&updateServiceCacheStub{},
|
||||
&updateServiceGitHubClientStub{recentErr: errors.New("github unavailable")},
|
||||
"0.1.147",
|
||||
"release",
|
||||
)
|
||||
|
||||
_, err := svc.ListRollbackVersions(context.Background())
|
||||
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "github unavailable")
|
||||
}
|
||||
|
||||
func TestUpdateServiceRollbackToVersionRejectsDisallowedTargets(t *testing.T) {
|
||||
releases := []*GitHubRelease{
|
||||
{TagName: "v0.1.148"},
|
||||
{TagName: "v0.1.147"},
|
||||
{TagName: "v0.1.146"},
|
||||
{TagName: "v0.1.145"},
|
||||
{TagName: "v0.1.144"},
|
||||
{TagName: "v0.1.143"},
|
||||
{TagName: "v0.1.142"},
|
||||
}
|
||||
svc := newRollbackTestService("0.1.147", releases)
|
||||
|
||||
for _, target := range []string{
|
||||
"", // empty
|
||||
"0.1.147", // current version
|
||||
"v0.1.147", // current version with prefix
|
||||
"0.1.148", // newer than current
|
||||
"0.1.142", // older than the 3 most recent
|
||||
"9.9.9", // nonexistent
|
||||
} {
|
||||
err := svc.RollbackToVersion(context.Background(), target)
|
||||
require.ErrorIs(t, err, ErrRollbackVersionNotAllowed, "target %q should be rejected", target)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateServiceRollbackToVersionAcceptsVPrefix(t *testing.T) {
|
||||
// No platform asset in the release: the target passes the allowlist check
|
||||
// and fails later at asset lookup, proving the version itself was accepted.
|
||||
releases := []*GitHubRelease{
|
||||
{TagName: "v0.1.147"},
|
||||
{TagName: "v0.1.146"},
|
||||
}
|
||||
svc := newRollbackTestService("0.1.147", releases)
|
||||
|
||||
err := svc.RollbackToVersion(context.Background(), "v0.1.146")
|
||||
|
||||
require.Error(t, err)
|
||||
require.NotErrorIs(t, err, ErrRollbackVersionNotAllowed)
|
||||
require.Contains(t, err.Error(), "no compatible release found")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user