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:
rcourtman
2026-07-10 01:46:08 +01:00
parent eb63de0b75
commit a0170d3252
23 changed files with 1434 additions and 17 deletions
+1
View File
@@ -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
View File
@@ -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 {
+148
View File
@@ -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
View File
@@ -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"}
+341
View File
@@ -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)
}
})
}