mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-23 20:25:56 +00:00
Block silent downgrades on apply and give the updater a sanctioned rollback path
The in-app updater validated download URL and channel but never compared the target against the running version, so any valid older release asset URL installed silently while the UI presented it as an update. ApplyUpdate now rejects targets at or below the running version on both the community and Pro broker paths before any history entry or download, with an explicit allowDowngrade opt-in on POST /api/updates/apply for sanctioned cases. The rollback half already existed but nothing reached it: createBackup retains three backups, restoreBackup works, and history records BackupPath, yet no endpoint or UI called restoreBackup. RollbackToBackup restores the retained backup recorded on a history entry after re-validating the path against the managed backup roots, shares the update-in-flight slot with ApplyUpdate, records an Action rollback history entry linked to the source update, marks that update rolled_back, streams a restoring stage through the existing status/SSE machinery, and restarts via the exit-for-systemd path. POST /api/updates/rollback carries it with the same RequireAdmin plus settings:write gating as apply. Rollback is purely local, so the Pro edition gate never applies to it. Settings now has the update history surface that was missing entirely: nothing called /api/updates/history before. The Updates panel lists recent updates with a Roll back action on successful entries whose backup is still retained, behind a confirmation dialog naming the restore version, and the rollback rides updateStore's shared pending-apply marker for the post-restart toast. restoreBackup also honors PULSE_INSTALL_DIR now instead of hardcoding /opt/pulse, matching createBackup. Contract deltas ride along: api-contracts picks up the rollback transport and downgrade-conflict semantics, agent-lifecycle and storage-recovery pin rollback as server self-update plumbing, ai-runtime and cloud-paid pin the update watcher stage vocabulary as non-assistant non-paid shell chrome, and frontend-primitives adds UpdateHistorySection as the history/rollback presentation owner with matching architecture proofs.
This commit is contained in:
@@ -447,6 +447,7 @@ var allRouteAllowlist = []string{
|
||||
"GET /api/onboarding/deep-link",
|
||||
"/api/updates/check",
|
||||
"/api/updates/apply",
|
||||
"/api/updates/rollback",
|
||||
"/api/updates/status",
|
||||
"/api/updates/stream",
|
||||
"/api/updates/plan",
|
||||
|
||||
@@ -125,6 +125,7 @@ func (r *Router) registerConfigSystemRoutes(updateHandlers *UpdateHandlers) {
|
||||
// Update routes
|
||||
r.mux.HandleFunc("/api/updates/check", RequireAdmin(r.config, RequireScope(config.ScopeSettingsRead, updateHandlers.HandleCheckUpdates)))
|
||||
r.mux.HandleFunc("/api/updates/apply", RequireAdmin(r.config, RequireScope(config.ScopeSettingsWrite, updateHandlers.HandleApplyUpdate)))
|
||||
r.mux.HandleFunc("/api/updates/rollback", RequireAdmin(r.config, RequireScope(config.ScopeSettingsWrite, updateHandlers.HandleRollbackUpdate)))
|
||||
r.mux.HandleFunc("/api/updates/status", RequireAdmin(r.config, RequireScope(config.ScopeSettingsRead, updateHandlers.HandleUpdateStatus)))
|
||||
r.mux.HandleFunc("/api/updates/stream", RequireAdmin(r.config, RequireScope(config.ScopeSettingsRead, updateHandlers.HandleUpdateStream)))
|
||||
r.mux.HandleFunc("/api/updates/plan", RequireAdmin(r.config, RequireScope(config.ScopeSettingsRead, updateHandlers.HandleGetUpdatePlan)))
|
||||
|
||||
+94
-5
@@ -36,6 +36,7 @@ type UpdateHandlers struct {
|
||||
type UpdateManager interface {
|
||||
CheckForUpdatesWithChannel(ctx context.Context, channel string) (*updates.UpdateInfo, error)
|
||||
ApplyUpdate(ctx context.Context, req updates.ApplyUpdateRequest) error
|
||||
RollbackToBackup(ctx context.Context, req updates.RollbackRequest) error
|
||||
GetStatus() updates.UpdateStatus
|
||||
GetSSECachedStatus() (updates.UpdateStatus, time.Time)
|
||||
AddSSEClient(w http.ResponseWriter, clientID string) *updates.SSEClient
|
||||
@@ -134,6 +135,9 @@ func (h *UpdateHandlers) HandleApplyUpdate(w http.ResponseWriter, r *http.Reques
|
||||
|
||||
var req struct {
|
||||
DownloadURL string `json:"downloadUrl"`
|
||||
// AllowDowngrade opts in to installing a target at or below the
|
||||
// running version; without it the manager rejects downgrades.
|
||||
AllowDowngrade bool `json:"allowDowngrade"`
|
||||
}
|
||||
|
||||
if err := decodeStrictJSONBody(r.Body, &req); err != nil {
|
||||
@@ -184,10 +188,11 @@ func (h *UpdateHandlers) HandleApplyUpdate(w http.ResponseWriter, r *http.Reques
|
||||
}
|
||||
|
||||
applyReq := updates.ApplyUpdateRequest{
|
||||
DownloadURL: req.DownloadURL,
|
||||
Channel: channel,
|
||||
InitiatedBy: updates.InitiatedByUser,
|
||||
InitiatedVia: updates.InitiatedViaUI,
|
||||
DownloadURL: req.DownloadURL,
|
||||
Channel: channel,
|
||||
InitiatedBy: updates.InitiatedByUser,
|
||||
InitiatedVia: updates.InitiatedViaUI,
|
||||
AllowDowngrade: req.AllowDowngrade,
|
||||
}
|
||||
result := make(chan error, 1)
|
||||
|
||||
@@ -255,7 +260,8 @@ func classifyApplyUpdateStartError(err error) (int, string) {
|
||||
return http.StatusConflict, "Update already in progress"
|
||||
case strings.Contains(errMsg, "download url is required"), strings.Contains(errMsg, "invalid download url"):
|
||||
return http.StatusBadRequest, "Invalid download URL"
|
||||
case strings.Contains(errMsg, "stable channel cannot install prerelease builds"):
|
||||
case strings.Contains(errMsg, "stable channel cannot install prerelease builds"),
|
||||
strings.Contains(errMsg, "not newer than the running version"):
|
||||
return http.StatusConflict, err.Error()
|
||||
case strings.Contains(errMsg, "cannot be applied in docker environment"),
|
||||
strings.Contains(errMsg, "manual migration required"),
|
||||
@@ -266,6 +272,89 @@ func classifyApplyUpdateStartError(err error) (int, string) {
|
||||
}
|
||||
}
|
||||
|
||||
// HandleRollbackUpdate handles rollback requests: it restores the retained
|
||||
// backup recorded on the selected update history entry.
|
||||
func (h *UpdateHandlers) HandleRollbackUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
// Limit request body to 8KB to prevent memory exhaustion
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 8*1024)
|
||||
defer r.Body.Close()
|
||||
|
||||
var req struct {
|
||||
EventID string `json:"eventId"`
|
||||
}
|
||||
|
||||
if err := decodeStrictJSONBody(r.Body, &req); err != nil {
|
||||
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
req.EventID = strings.TrimSpace(req.EventID)
|
||||
|
||||
if req.EventID == "" {
|
||||
http.Error(w, "Event ID is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
rollbackReq := updates.RollbackRequest{
|
||||
EventID: req.EventID,
|
||||
InitiatedBy: updates.InitiatedByUser,
|
||||
InitiatedVia: updates.InitiatedViaUI,
|
||||
}
|
||||
result := make(chan error, 1)
|
||||
|
||||
// Start rollback in background with a new context (not request context which gets canceled)
|
||||
go func() {
|
||||
result <- h.manager.RollbackToBackup(context.Background(), rollbackReq)
|
||||
}()
|
||||
|
||||
select {
|
||||
case err := <-result:
|
||||
if err != nil {
|
||||
statusCode, msg := classifyRollbackStartError(err)
|
||||
if statusCode >= http.StatusInternalServerError {
|
||||
log.Error().Err(err).Str("event_id", req.EventID).Msg("Failed to start rollback")
|
||||
} else {
|
||||
log.Warn().Err(err).Str("event_id", req.EventID).Msg("Rollback request rejected")
|
||||
}
|
||||
http.Error(w, msg, statusCode)
|
||||
return
|
||||
}
|
||||
case <-time.After(applyUpdateStartAckTimeout):
|
||||
}
|
||||
|
||||
// Return success immediately
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(map[string]string{
|
||||
"status": "started",
|
||||
"message": "Rollback process started",
|
||||
}); err != nil {
|
||||
log.Error().Err(err).Msg("Failed to encode rollback start response")
|
||||
}
|
||||
}
|
||||
|
||||
func classifyRollbackStartError(err error) (int, string) {
|
||||
errMsg := strings.ToLower(strings.TrimSpace(err.Error()))
|
||||
switch {
|
||||
case strings.Contains(errMsg, "already in progress"):
|
||||
return http.StatusConflict, "Update already in progress"
|
||||
case strings.Contains(errMsg, "event id is required"):
|
||||
return http.StatusBadRequest, "Event ID is required"
|
||||
case strings.Contains(errMsg, "history entry not found"):
|
||||
return http.StatusNotFound, "Update history entry not found"
|
||||
case strings.Contains(errMsg, "no retained backup"),
|
||||
strings.Contains(errMsg, "backup no longer exists"),
|
||||
strings.Contains(errMsg, "not a managed update backup"),
|
||||
strings.Contains(errMsg, "cannot be applied in docker environment"):
|
||||
return http.StatusConflict, err.Error()
|
||||
default:
|
||||
return http.StatusInternalServerError, "Failed to start rollback"
|
||||
}
|
||||
}
|
||||
|
||||
// HandleUpdateStatus handles update status requests with rate limiting
|
||||
func (h *UpdateHandlers) HandleUpdateStatus(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
type MockUpdateManager struct {
|
||||
CheckForUpdatesFunc func(ctx context.Context, channel string) (*updates.UpdateInfo, error)
|
||||
ApplyUpdateFunc func(ctx context.Context, req updates.ApplyUpdateRequest) error
|
||||
RollbackToBackupFunc func(ctx context.Context, req updates.RollbackRequest) error
|
||||
GetStatusFunc func() updates.UpdateStatus
|
||||
GetSSECachedStatusFunc func() (updates.UpdateStatus, time.Time)
|
||||
AddSSEClientFunc func(w http.ResponseWriter, clientID string) *updates.SSEClient
|
||||
@@ -39,6 +40,13 @@ func (m *MockUpdateManager) ApplyUpdate(ctx context.Context, req updates.ApplyUp
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MockUpdateManager) RollbackToBackup(ctx context.Context, req updates.RollbackRequest) error {
|
||||
if m.RollbackToBackupFunc != nil {
|
||||
return m.RollbackToBackupFunc(ctx, req)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MockUpdateManager) GetStatus() updates.UpdateStatus {
|
||||
if m.GetStatusFunc != nil {
|
||||
return m.GetStatusFunc()
|
||||
@@ -155,6 +163,146 @@ func TestHandleApplyUpdate_Success(t *testing.T) {
|
||||
// Note: ApplyUpdate runs in background, so we just check it was accepted
|
||||
}
|
||||
|
||||
func TestHandleApplyUpdate_PassesAllowDowngrade(t *testing.T) {
|
||||
received := make(chan updates.ApplyUpdateRequest, 1)
|
||||
mockManager := &MockUpdateManager{
|
||||
ApplyUpdateFunc: func(ctx context.Context, req updates.ApplyUpdateRequest) error {
|
||||
received <- req
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
h := NewUpdateHandlers(mockManager, nil)
|
||||
w := httptest.NewRecorder()
|
||||
body := `{"downloadUrl": "https://example.com/update.tar.gz", "allowDowngrade": true}`
|
||||
r := httptest.NewRequest(http.MethodPost, "/updates/apply", strings.NewReader(body))
|
||||
|
||||
h.HandleApplyUpdate(w, r)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("Expected status 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
select {
|
||||
case req := <-received:
|
||||
if !req.AllowDowngrade {
|
||||
t.Fatal("expected AllowDowngrade to be passed through to the manager request")
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("ApplyUpdate was not invoked")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleRollbackUpdate_Success(t *testing.T) {
|
||||
received := make(chan updates.RollbackRequest, 1)
|
||||
mockManager := &MockUpdateManager{
|
||||
RollbackToBackupFunc: func(ctx context.Context, req updates.RollbackRequest) error {
|
||||
received <- req
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
h := NewUpdateHandlers(mockManager, nil)
|
||||
w := httptest.NewRecorder()
|
||||
body := `{"eventId": "01JZEXAMPLE"}`
|
||||
r := httptest.NewRequest(http.MethodPost, "/updates/rollback", strings.NewReader(body))
|
||||
|
||||
h.HandleRollbackUpdate(w, r)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("Expected status 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp map[string]string
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if resp["status"] != "started" {
|
||||
t.Fatalf("expected started status, got %q", resp["status"])
|
||||
}
|
||||
select {
|
||||
case req := <-received:
|
||||
if req.EventID != "01JZEXAMPLE" {
|
||||
t.Fatalf("expected event ID to be passed through, got %q", req.EventID)
|
||||
}
|
||||
if req.InitiatedBy != updates.InitiatedByUser || req.InitiatedVia != updates.InitiatedViaUI {
|
||||
t.Fatalf("expected user/ui initiation, got %q/%q", req.InitiatedBy, req.InitiatedVia)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("RollbackToBackup was not invoked")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleRollbackUpdate_InvalidRequests(t *testing.T) {
|
||||
mockManager := &MockUpdateManager{
|
||||
RollbackToBackupFunc: func(ctx context.Context, req updates.RollbackRequest) error {
|
||||
t.Fatal("RollbackToBackup should not be called for invalid requests")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
h := NewUpdateHandlers(mockManager, nil)
|
||||
|
||||
t.Run("rejects non-POST", func(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest(http.MethodGet, "/updates/rollback", nil)
|
||||
h.HandleRollbackUpdate(w, r)
|
||||
if w.Code != http.StatusMethodNotAllowed {
|
||||
t.Fatalf("Expected status %d, got %d", http.StatusMethodNotAllowed, w.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rejects unknown fields", func(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
body := `{"eventId":"01JZEXAMPLE","unexpected":true}`
|
||||
r := httptest.NewRequest(http.MethodPost, "/updates/rollback", strings.NewReader(body))
|
||||
h.HandleRollbackUpdate(w, r)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("Expected status %d, got %d", http.StatusBadRequest, w.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rejects missing event ID", func(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
body := `{"eventId":" "}`
|
||||
r := httptest.NewRequest(http.MethodPost, "/updates/rollback", strings.NewReader(body))
|
||||
h.HandleRollbackUpdate(w, r)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("Expected status %d, got %d", http.StatusBadRequest, w.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestHandleRollbackUpdate_ErrorMapping(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
managerErr error
|
||||
wantCode int
|
||||
}{
|
||||
{"entry not found", errors.New("update history entry not found: x"), http.StatusNotFound},
|
||||
{"backup pruned", errors.New("no retained backup for this update; it may have been pruned by backup retention"), http.StatusConflict},
|
||||
{"backup missing on disk", errors.New("backup no longer exists on disk: /var/lib/pulse/backup-1"), http.StatusConflict},
|
||||
{"unmanaged path", errors.New("backup path is not a managed update backup: /etc/passwd"), http.StatusConflict},
|
||||
{"docker", errors.New("rollback cannot be applied in Docker environment"), http.StatusConflict},
|
||||
{"in progress", errors.New("update already in progress"), http.StatusConflict},
|
||||
{"unexpected", errors.New("disk exploded"), http.StatusInternalServerError},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
mockManager := &MockUpdateManager{
|
||||
RollbackToBackupFunc: func(ctx context.Context, req updates.RollbackRequest) error {
|
||||
return tc.managerErr
|
||||
},
|
||||
}
|
||||
h := NewUpdateHandlers(mockManager, nil)
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest(http.MethodPost, "/updates/rollback", strings.NewReader(`{"eventId":"01JZEXAMPLE"}`))
|
||||
h.HandleRollbackUpdate(w, r)
|
||||
if w.Code != tc.wantCode {
|
||||
t.Fatalf("expected status %d, got %d: %s", tc.wantCode, w.Code, w.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleApplyUpdate_AlreadyInProgress(t *testing.T) {
|
||||
mockManager := &MockUpdateManager{
|
||||
ApplyUpdateFunc: func(ctx context.Context, req updates.ApplyUpdateRequest) error {
|
||||
|
||||
+195
-1
@@ -201,6 +201,19 @@ type ApplyUpdateRequest struct {
|
||||
InitiatedBy InitiatedBy
|
||||
InitiatedVia InitiatedVia
|
||||
Notes string
|
||||
// AllowDowngrade permits installing a target at or below the running
|
||||
// version. The normal apply path rejects those so a valid-but-older
|
||||
// release asset URL cannot silently downgrade; sanctioned rollbacks go
|
||||
// through RollbackToBackup instead.
|
||||
AllowDowngrade bool
|
||||
}
|
||||
|
||||
// RollbackRequest describes a rollback of a recorded update, restoring the
|
||||
// retained backup captured before that update was applied.
|
||||
type RollbackRequest struct {
|
||||
EventID string
|
||||
InitiatedBy InitiatedBy
|
||||
InitiatedVia InitiatedVia
|
||||
}
|
||||
|
||||
// NewManager creates a new update manager
|
||||
@@ -604,10 +617,23 @@ func (m *Manager) ApplyUpdate(ctx context.Context, req ApplyUpdateRequest) error
|
||||
} else {
|
||||
targetVersion, validationErr := ValidateApplyTargetVersion(channel, req.DownloadURL)
|
||||
if validationErr != nil {
|
||||
m.updateStatus("error", 10, "Update rejected", validationErr)
|
||||
return validationErr
|
||||
}
|
||||
artifact = resolvedUpdateArtifact{downloadURL: req.DownloadURL, version: targetVersion}
|
||||
}
|
||||
|
||||
// A valid release asset URL can still point at an older release than the
|
||||
// running binary; without this guard the "update" would silently install a
|
||||
// downgrade. Sanctioned downgrades either restore a retained backup via
|
||||
// RollbackToBackup or set AllowDowngrade explicitly on the request.
|
||||
if !req.AllowDowngrade {
|
||||
if err := ensureApplyTargetIsNewer(currentInfo.Version, artifact.version); err != nil {
|
||||
m.updateStatus("error", 10, "Update rejected", err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
initiatedBy := req.InitiatedBy
|
||||
if initiatedBy == "" {
|
||||
initiatedBy = InitiatedByUser
|
||||
@@ -1663,9 +1689,177 @@ func (m *Manager) createBackup(ctx context.Context) (string, error) {
|
||||
return backupDir, nil
|
||||
}
|
||||
|
||||
// ensureApplyTargetIsNewer rejects update targets at or below the running
|
||||
// version so a valid-but-older release asset URL cannot silently downgrade.
|
||||
// Versions that do not parse as semver (development builds) are left to the
|
||||
// existing URL and channel validation.
|
||||
func ensureApplyTargetIsNewer(currentVersion, targetVersion string) error {
|
||||
current, err := ParseVersion(currentVersion)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
target, err := ParseVersion(targetVersion)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
if target.Compare(current) <= 0 {
|
||||
return fmt.Errorf("target version %s is not newer than the running version %s; use the update history rollback to return to an earlier version", target.String(), current.String())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateRetainedBackupDir confirms a history-recorded backup path still
|
||||
// points at an existing managed update backup directory before anything is
|
||||
// restored from it. The history file is server-owned state, but the path is
|
||||
// re-checked against the managed backup roots so a corrupted or hand-edited
|
||||
// history entry cannot direct a restore from an arbitrary filesystem location.
|
||||
func validateRetainedBackupDir(raw string) (string, error) {
|
||||
cleaned := filepath.Clean(strings.TrimSpace(raw))
|
||||
managed := false
|
||||
for _, root := range managedUpdateBackupRoots() {
|
||||
if filepath.Dir(cleaned) != root {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(filepath.Base(cleaned), managedUpdateBackupPrefix(root)) {
|
||||
managed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !managed {
|
||||
return "", fmt.Errorf("backup path is not a managed update backup: %s", cleaned)
|
||||
}
|
||||
|
||||
info, err := os.Stat(cleaned)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return "", fmt.Errorf("backup no longer exists on disk: %s", cleaned)
|
||||
}
|
||||
return "", fmt.Errorf("stat backup directory %q: %w", cleaned, err)
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return "", fmt.Errorf("backup path is not a directory: %s", cleaned)
|
||||
}
|
||||
return cleaned, nil
|
||||
}
|
||||
|
||||
// RollbackToBackup restores the retained backup recorded on an update history
|
||||
// entry, records the rollback as its own history entry, and restarts through
|
||||
// the same exit-for-systemd path as ApplyUpdate. It is a purely local
|
||||
// restore: no release download, broker call, or edition gate is involved, so
|
||||
// it behaves identically on community and Pro binaries.
|
||||
func (m *Manager) RollbackToBackup(ctx context.Context, req RollbackRequest) error {
|
||||
if m.history == nil {
|
||||
return fmt.Errorf("update history is not available")
|
||||
}
|
||||
eventID := strings.TrimSpace(req.EventID)
|
||||
if eventID == "" {
|
||||
return fmt.Errorf("event ID is required")
|
||||
}
|
||||
|
||||
source, err := m.history.GetEntry(eventID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update history entry not found: %s", eventID)
|
||||
}
|
||||
if strings.TrimSpace(source.BackupPath) == "" {
|
||||
return fmt.Errorf("no retained backup for this update; it may have been pruned by backup retention")
|
||||
}
|
||||
backupDir, err := validateRetainedBackupDir(source.BackupPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
currentInfo, _ := GetCurrentVersion()
|
||||
if currentInfo.IsDocker {
|
||||
return fmt.Errorf("rollback cannot be applied in Docker environment")
|
||||
}
|
||||
|
||||
// Rollback and update share the single in-flight slot: restoring a backup
|
||||
// while an update is replacing the same files would corrupt both.
|
||||
m.updateMu.Lock()
|
||||
if m.updateInFlight {
|
||||
m.updateMu.Unlock()
|
||||
return fmt.Errorf("update already in progress")
|
||||
}
|
||||
m.updateInFlight = true
|
||||
m.updateMu.Unlock()
|
||||
defer func() {
|
||||
m.updateMu.Lock()
|
||||
m.updateInFlight = false
|
||||
m.updateMu.Unlock()
|
||||
}()
|
||||
|
||||
initiatedBy := req.InitiatedBy
|
||||
if initiatedBy == "" {
|
||||
initiatedBy = InitiatedByUser
|
||||
}
|
||||
initiatedVia := req.InitiatedVia
|
||||
if initiatedVia == "" {
|
||||
initiatedVia = InitiatedViaAPI
|
||||
}
|
||||
|
||||
m.updateStatus("restoring", 20, fmt.Sprintf("Restoring Pulse %s from backup...", source.VersionFrom))
|
||||
|
||||
start := time.Now()
|
||||
rollbackEventID := m.createHistoryEntry(ctx, UpdateHistoryEntry{
|
||||
Action: "rollback",
|
||||
Channel: source.Channel,
|
||||
VersionFrom: currentInfo.Version,
|
||||
VersionTo: source.VersionFrom,
|
||||
DeploymentType: currentInfo.DeploymentType,
|
||||
InitiatedBy: initiatedBy,
|
||||
InitiatedVia: initiatedVia,
|
||||
Status: StatusInProgress,
|
||||
BackupPath: backupDir,
|
||||
RelatedEventID: source.EventID,
|
||||
})
|
||||
|
||||
var runErr error
|
||||
defer func() {
|
||||
if rollbackEventID == "" {
|
||||
return
|
||||
}
|
||||
status := StatusSuccess
|
||||
if runErr != nil {
|
||||
status = StatusFailed
|
||||
}
|
||||
m.completeHistoryEntry(ctx, rollbackEventID, status, start, runErr)
|
||||
}()
|
||||
|
||||
if err := m.restoreBackup(backupDir); err != nil {
|
||||
restoreErr := fmt.Errorf("failed to restore backup: %w", err)
|
||||
m.updateStatus("error", 40, "Failed to restore backup", restoreErr)
|
||||
runErr = restoreErr
|
||||
return restoreErr
|
||||
}
|
||||
|
||||
// The update this backup predates is no longer the running install.
|
||||
m.updateHistoryEntry(ctx, eventID, func(entry *UpdateHistoryEntry) {
|
||||
entry.Status = StatusRolledBack
|
||||
})
|
||||
|
||||
m.updateStatus("restarting", 95, "Restarting service...")
|
||||
|
||||
// Schedule a clean exit after a short delay - systemd will restart us
|
||||
if !dockerUpdatesAllowed() {
|
||||
go func() {
|
||||
time.Sleep(2 * time.Second)
|
||||
log.Info().Msg("Exiting for restart after rollback")
|
||||
os.Exit(0)
|
||||
}()
|
||||
} else {
|
||||
log.Info().Msg("Skipping process exit after rollback (mock/CI mode)")
|
||||
}
|
||||
|
||||
m.updateStatus("completed", 100, "Rollback completed, restarting...")
|
||||
return nil
|
||||
}
|
||||
|
||||
// restoreBackup restores from a backup
|
||||
func (m *Manager) restoreBackup(backupDir string) error {
|
||||
pulseDir := "/opt/pulse"
|
||||
pulseDir := os.Getenv("PULSE_INSTALL_DIR")
|
||||
if pulseDir == "" {
|
||||
pulseDir = "/opt/pulse"
|
||||
}
|
||||
|
||||
// Restore directories
|
||||
dirsToRestore := []string{"data", "config"}
|
||||
|
||||
@@ -0,0 +1,341 @@
|
||||
package updates
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestEnsureApplyTargetIsNewer(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
current string
|
||||
target string
|
||||
wantErr bool
|
||||
}{
|
||||
{"newer patch allowed", "6.0.4", "6.0.5", false},
|
||||
{"older patch blocked", "6.0.5", "6.0.4", true},
|
||||
{"same version blocked", "6.0.5", "6.0.5", true},
|
||||
{"rc to stable of same base allowed", "6.0.5-rc.4", "6.0.5", false},
|
||||
{"stable to rc of same base blocked", "6.0.5", "6.0.5-rc.4", true},
|
||||
{"older rc blocked", "6.0.5-rc.4", "6.0.5-rc.3", true},
|
||||
{"newer rc allowed", "6.0.5-rc.4", "6.0.5-rc.5", false},
|
||||
{"v prefix handled", "v6.0.5", "v6.0.6", false},
|
||||
{"unparseable current allowed", "not-a-version", "6.0.4", false},
|
||||
{"unparseable target allowed", "6.0.5", "not-a-version", false},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := ensureApplyTargetIsNewer(tc.current, tc.target)
|
||||
if tc.wantErr && err == nil {
|
||||
t.Fatalf("expected downgrade error for current=%s target=%s", tc.current, tc.target)
|
||||
}
|
||||
if !tc.wantErr && err != nil {
|
||||
t.Fatalf("unexpected error for current=%s target=%s: %v", tc.current, tc.target, err)
|
||||
}
|
||||
if err != nil && !strings.Contains(err.Error(), "not newer than the running version") {
|
||||
t.Fatalf("expected canonical downgrade message, got %q", err.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyUpdateRejectsDowngradeBeforeDownload(t *testing.T) {
|
||||
t.Setenv("PULSE_ALLOW_DOCKER_UPDATES", "true")
|
||||
t.Setenv("PULSE_DATA_DIR", t.TempDir())
|
||||
t.Setenv("PULSE_INSTALL_DIR", t.TempDir())
|
||||
|
||||
oldBuildVersion := BuildVersion
|
||||
BuildVersion = "6.0.5"
|
||||
t.Cleanup(func() { BuildVersion = oldBuildVersion })
|
||||
|
||||
manager := &Manager{}
|
||||
|
||||
err := manager.ApplyUpdate(context.Background(), ApplyUpdateRequest{
|
||||
DownloadURL: "https://github.com/rcourtman/Pulse/releases/download/v6.0.4/pulse-v6.0.4-linux-amd64.tar.gz",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected downgrade to be rejected")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "not newer than the running version") {
|
||||
t.Fatalf("expected downgrade rejection, got %v", err)
|
||||
}
|
||||
|
||||
status := manager.GetStatus()
|
||||
if status.Status != "error" {
|
||||
t.Fatalf("expected error status after rejected downgrade, got %q", status.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyUpdateAllowDowngradeSkipsGuard(t *testing.T) {
|
||||
t.Setenv("PULSE_ALLOW_DOCKER_UPDATES", "true")
|
||||
t.Setenv("PULSE_DATA_DIR", t.TempDir())
|
||||
t.Setenv("PULSE_INSTALL_DIR", t.TempDir())
|
||||
|
||||
// Serve 404s locally so the apply fails fast at the download stage
|
||||
// without touching the real release host.
|
||||
server := httptest.NewServer(http.NotFoundHandler())
|
||||
t.Cleanup(server.Close)
|
||||
t.Setenv("PULSE_UPDATE_SERVER", server.URL)
|
||||
|
||||
oldBuildVersion := BuildVersion
|
||||
BuildVersion = "6.0.5"
|
||||
t.Cleanup(func() { BuildVersion = oldBuildVersion })
|
||||
|
||||
manager := &Manager{}
|
||||
|
||||
// With the opt-in set the request must get past the downgrade guard; it
|
||||
// then fails later at the download step instead.
|
||||
err := manager.ApplyUpdate(context.Background(), ApplyUpdateRequest{
|
||||
DownloadURL: server.URL + "/releases/download/v6.0.4/pulse-v6.0.4-linux-amd64.tar.gz",
|
||||
AllowDowngrade: true,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected apply to fail at a later stage in this environment")
|
||||
}
|
||||
if strings.Contains(err.Error(), "not newer than the running version") {
|
||||
t.Fatalf("expected the downgrade guard to be skipped, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRetainedBackupDir(t *testing.T) {
|
||||
dataDir := t.TempDir()
|
||||
t.Setenv("PULSE_DATA_DIR", dataDir)
|
||||
|
||||
valid := filepath.Join(dataDir, "backup-20260710-010101")
|
||||
if err := os.MkdirAll(valid, 0755); err != nil {
|
||||
t.Fatalf("mkdir valid backup: %v", err)
|
||||
}
|
||||
|
||||
t.Run("accepts managed backup", func(t *testing.T) {
|
||||
got, err := validateRetainedBackupDir(valid)
|
||||
if err != nil {
|
||||
t.Fatalf("expected managed backup to validate: %v", err)
|
||||
}
|
||||
if got != filepath.Clean(valid) {
|
||||
t.Fatalf("expected %q, got %q", valid, got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rejects path outside managed roots", func(t *testing.T) {
|
||||
outside := filepath.Join(t.TempDir(), "backup-20260710-010101")
|
||||
if err := os.MkdirAll(outside, 0755); err != nil {
|
||||
t.Fatalf("mkdir outside backup: %v", err)
|
||||
}
|
||||
if _, err := validateRetainedBackupDir(outside); err == nil {
|
||||
t.Fatal("expected unmanaged path to be rejected")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rejects wrong prefix inside managed root", func(t *testing.T) {
|
||||
wrongPrefix := filepath.Join(dataDir, "snapshots-20260710-010101")
|
||||
if err := os.MkdirAll(wrongPrefix, 0755); err != nil {
|
||||
t.Fatalf("mkdir wrong prefix: %v", err)
|
||||
}
|
||||
if _, err := validateRetainedBackupDir(wrongPrefix); err == nil {
|
||||
t.Fatal("expected wrong-prefix path to be rejected")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rejects missing directory", func(t *testing.T) {
|
||||
missing := filepath.Join(dataDir, "backup-19990101-000000")
|
||||
_, err := validateRetainedBackupDir(missing)
|
||||
if err == nil || !strings.Contains(err.Error(), "no longer exists") {
|
||||
t.Fatalf("expected missing-backup error, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestRollbackToBackupRestoresFilesAndRecordsHistory(t *testing.T) {
|
||||
t.Setenv("PULSE_ALLOW_DOCKER_UPDATES", "true")
|
||||
dataDir := t.TempDir()
|
||||
installDir := t.TempDir()
|
||||
t.Setenv("PULSE_DATA_DIR", dataDir)
|
||||
t.Setenv("PULSE_INSTALL_DIR", installDir)
|
||||
|
||||
// Backup contents captured before the recorded update. The backup holds
|
||||
// no "pulse" binary on purpose: restoring one would overwrite the running
|
||||
// test executable via os.Executable.
|
||||
backupDir := filepath.Join(dataDir, "backup-20260710-020202")
|
||||
if err := os.MkdirAll(filepath.Join(backupDir, "config"), 0755); err != nil {
|
||||
t.Fatalf("mkdir backup config: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(backupDir, "config", "system.json"), []byte(`{"restored":true}`), 0600); err != nil {
|
||||
t.Fatalf("write backup config: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(backupDir, ".env"), []byte("RESTORED=1\n"), 0600); err != nil {
|
||||
t.Fatalf("write backup env: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(backupDir, "VERSION"), []byte("6.0.4\n"), 0644); err != nil {
|
||||
t.Fatalf("write backup VERSION: %v", err)
|
||||
}
|
||||
|
||||
// Current install state that the rollback must replace.
|
||||
if err := os.MkdirAll(filepath.Join(installDir, "config"), 0755); err != nil {
|
||||
t.Fatalf("mkdir install config: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(installDir, "config", "system.json"), []byte(`{"restored":false}`), 0600); err != nil {
|
||||
t.Fatalf("write install config: %v", err)
|
||||
}
|
||||
|
||||
history, err := NewUpdateHistory(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatalf("NewUpdateHistory: %v", err)
|
||||
}
|
||||
sourceEventID, err := history.CreateEntry(context.Background(), UpdateHistoryEntry{
|
||||
Action: "update",
|
||||
Status: StatusSuccess,
|
||||
VersionFrom: "6.0.4",
|
||||
VersionTo: "6.0.5",
|
||||
BackupPath: backupDir,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateEntry: %v", err)
|
||||
}
|
||||
|
||||
manager := &Manager{history: history}
|
||||
|
||||
if err := manager.RollbackToBackup(context.Background(), RollbackRequest{EventID: sourceEventID}); err != nil {
|
||||
t.Fatalf("RollbackToBackup: %v", err)
|
||||
}
|
||||
|
||||
restoredConfig, err := os.ReadFile(filepath.Join(installDir, "config", "system.json"))
|
||||
if err != nil {
|
||||
t.Fatalf("read restored config: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(restoredConfig), `"restored":true`) {
|
||||
t.Fatalf("expected config to be restored from backup, got %s", restoredConfig)
|
||||
}
|
||||
restoredEnv, err := os.ReadFile(filepath.Join(installDir, ".env"))
|
||||
if err != nil {
|
||||
t.Fatalf("read restored .env: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(restoredEnv), "RESTORED=1") {
|
||||
t.Fatalf("expected .env to be restored, got %s", restoredEnv)
|
||||
}
|
||||
|
||||
source, err := history.GetEntry(sourceEventID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetEntry source: %v", err)
|
||||
}
|
||||
if source.Status != StatusRolledBack {
|
||||
t.Fatalf("expected source entry to be marked rolled_back, got %q", source.Status)
|
||||
}
|
||||
|
||||
entries := history.ListEntries(HistoryFilter{Action: "rollback"})
|
||||
if len(entries) != 1 {
|
||||
t.Fatalf("expected one rollback history entry, got %d", len(entries))
|
||||
}
|
||||
rollback := entries[0]
|
||||
if rollback.Status != StatusSuccess {
|
||||
t.Fatalf("expected rollback entry success, got %q", rollback.Status)
|
||||
}
|
||||
if rollback.VersionTo != "6.0.4" {
|
||||
t.Fatalf("expected rollback target version 6.0.4, got %q", rollback.VersionTo)
|
||||
}
|
||||
if rollback.RelatedEventID != sourceEventID {
|
||||
t.Fatalf("expected rollback to reference the source update entry")
|
||||
}
|
||||
|
||||
status := manager.GetStatus()
|
||||
if status.Status != "completed" {
|
||||
t.Fatalf("expected completed status after rollback, got %q", status.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRollbackToBackupRejectsBadRequests(t *testing.T) {
|
||||
t.Setenv("PULSE_ALLOW_DOCKER_UPDATES", "true")
|
||||
dataDir := t.TempDir()
|
||||
t.Setenv("PULSE_DATA_DIR", dataDir)
|
||||
t.Setenv("PULSE_INSTALL_DIR", t.TempDir())
|
||||
|
||||
history, err := NewUpdateHistory(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatalf("NewUpdateHistory: %v", err)
|
||||
}
|
||||
manager := &Manager{history: history}
|
||||
|
||||
t.Run("no history sink", func(t *testing.T) {
|
||||
bare := &Manager{}
|
||||
if err := bare.RollbackToBackup(context.Background(), RollbackRequest{EventID: "x"}); err == nil {
|
||||
t.Fatal("expected error without history")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty event ID", func(t *testing.T) {
|
||||
err := manager.RollbackToBackup(context.Background(), RollbackRequest{EventID: " "})
|
||||
if err == nil || !strings.Contains(err.Error(), "event ID is required") {
|
||||
t.Fatalf("expected event ID error, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unknown entry", func(t *testing.T) {
|
||||
err := manager.RollbackToBackup(context.Background(), RollbackRequest{EventID: "does-not-exist"})
|
||||
if err == nil || !strings.Contains(err.Error(), "history entry not found") {
|
||||
t.Fatalf("expected not-found error, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("entry without backup", func(t *testing.T) {
|
||||
eventID, err := history.CreateEntry(context.Background(), UpdateHistoryEntry{
|
||||
Action: "update",
|
||||
Status: StatusSuccess,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateEntry: %v", err)
|
||||
}
|
||||
rollbackErr := manager.RollbackToBackup(context.Background(), RollbackRequest{EventID: eventID})
|
||||
if rollbackErr == nil || !strings.Contains(rollbackErr.Error(), "no retained backup") {
|
||||
t.Fatalf("expected no-backup error, got %v", rollbackErr)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("pruned backup directory", func(t *testing.T) {
|
||||
eventID, err := history.CreateEntry(context.Background(), UpdateHistoryEntry{
|
||||
Action: "update",
|
||||
Status: StatusSuccess,
|
||||
BackupPath: filepath.Join(dataDir, "backup-20200101-000000"),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateEntry: %v", err)
|
||||
}
|
||||
rollbackErr := manager.RollbackToBackup(context.Background(), RollbackRequest{EventID: eventID})
|
||||
if rollbackErr == nil || !strings.Contains(rollbackErr.Error(), "no longer exists") {
|
||||
t.Fatalf("expected missing-backup error, got %v", rollbackErr)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("concurrent update in flight", func(t *testing.T) {
|
||||
backupDir := filepath.Join(dataDir, "backup-20260710-030303")
|
||||
if err := os.MkdirAll(backupDir, 0755); err != nil {
|
||||
t.Fatalf("mkdir backup: %v", err)
|
||||
}
|
||||
eventID, err := history.CreateEntry(context.Background(), UpdateHistoryEntry{
|
||||
Action: "update",
|
||||
Status: StatusSuccess,
|
||||
BackupPath: backupDir,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateEntry: %v", err)
|
||||
}
|
||||
|
||||
manager.updateMu.Lock()
|
||||
manager.updateInFlight = true
|
||||
manager.updateMu.Unlock()
|
||||
t.Cleanup(func() {
|
||||
manager.updateMu.Lock()
|
||||
manager.updateInFlight = false
|
||||
manager.updateMu.Unlock()
|
||||
})
|
||||
|
||||
rollbackErr := manager.RollbackToBackup(context.Background(), RollbackRequest{EventID: eventID})
|
||||
if rollbackErr == nil || !strings.Contains(rollbackErr.Error(), "already in progress") {
|
||||
t.Fatalf("expected in-progress error, got %v", rollbackErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user