mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-23 12:15:42 +00:00
a0170d3252
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.
2752 lines
81 KiB
Go
2752 lines
81 KiB
Go
package updates
|
|
|
|
import (
|
|
"archive/tar"
|
|
"compress/gzip"
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"regexp"
|
|
"runtime"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/config"
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/securityutil"
|
|
"github.com/rcourtman/pulse-go-rewrite/pkg/edition"
|
|
"github.com/rs/zerolog/log"
|
|
godisk "github.com/shirou/gopsutil/v4/disk"
|
|
)
|
|
|
|
// UpdateStatus represents the current status of an update
|
|
type UpdateStatus struct {
|
|
Status string `json:"status"`
|
|
Progress int `json:"progress"`
|
|
Message string `json:"message"`
|
|
Error string `json:"error,omitempty"`
|
|
UpdatedAt string `json:"updatedAt"`
|
|
}
|
|
|
|
// ReleaseInfo represents a GitHub release
|
|
type ReleaseInfo struct {
|
|
TagName string `json:"tag_name"`
|
|
Name string `json:"name"`
|
|
Body string `json:"body"`
|
|
Prerelease bool `json:"prerelease"`
|
|
Draft bool `json:"draft"`
|
|
PublishedAt time.Time `json:"published_at"`
|
|
Assets []struct {
|
|
Name string `json:"name"`
|
|
BrowserDownloadURL string `json:"browser_download_url"`
|
|
} `json:"assets"`
|
|
}
|
|
|
|
// UpdateInfo represents available update information
|
|
type UpdateInfo struct {
|
|
Available bool `json:"available"`
|
|
CurrentVersion string `json:"currentVersion"`
|
|
LatestVersion string `json:"latestVersion"`
|
|
ReleaseNotes string `json:"releaseNotes"`
|
|
ReleaseDate time.Time `json:"releaseDate"`
|
|
DownloadURL string `json:"downloadUrl"`
|
|
IsPrerelease bool `json:"isPrerelease"`
|
|
IsMajorUpgrade bool `json:"isMajorUpgrade"`
|
|
Warning string `json:"warning,omitempty"`
|
|
}
|
|
|
|
var (
|
|
errGitHubRateLimited = errors.New("GitHub API rate limit exceeded")
|
|
stageDelayOnce sync.Once
|
|
stageDelayValue time.Duration
|
|
updateHTTPAttempts = 3
|
|
updateHTTPBackoff = 300 * time.Millisecond
|
|
updateHTTPMaxBackoff = 2 * time.Second
|
|
updateDiskUsage = godisk.UsageWithContext
|
|
)
|
|
|
|
const (
|
|
defaultUpdateReleaseRepo string = "rcourtman/Pulse"
|
|
defaultUpdateAPIBaseURL string = "https://api.github.com"
|
|
maxReleaseFeedBytes int64 = 1 << 20 // 1 MiB
|
|
maxChecksumFileBytes int64 = 1 << 20 // 1 MiB
|
|
maxUpdateDownloadBytes int64 = 512 << 20 // 512 MiB
|
|
minUpdateTempFreeBytes int64 = 128 << 20 // 128 MiB
|
|
updateBackupSafetyBytes int64 = 32 << 20 // 32 MiB
|
|
updateExtractSafetyBytes int64 = 32 << 20 // 32 MiB
|
|
maxRetainedUpdateBackups int = 3
|
|
)
|
|
|
|
func updateReleaseRepo() string {
|
|
repo := strings.TrimSpace(os.Getenv("PULSE_GITHUB_REPO"))
|
|
if repo == "" {
|
|
return defaultUpdateReleaseRepo
|
|
}
|
|
return repo
|
|
}
|
|
|
|
func updateReleaseDownloadPrefix() string {
|
|
return fmt.Sprintf("https://github.com/%s/releases/download/", updateReleaseRepo())
|
|
}
|
|
|
|
func updateReleaseAPIPath() string {
|
|
return fmt.Sprintf("/repos/%s/releases", updateReleaseRepo())
|
|
}
|
|
|
|
func updateReleaseAPIBaseURL() string {
|
|
baseURL := strings.TrimSpace(os.Getenv("PULSE_UPDATE_SERVER"))
|
|
if baseURL == "" {
|
|
return defaultUpdateAPIBaseURL
|
|
}
|
|
return baseURL
|
|
}
|
|
|
|
func resolveUpdateReleaseAPIURL() (*url.URL, error) {
|
|
baseURL, err := securityutil.NormalizeHTTPBaseURL(updateReleaseAPIBaseURL(), "https")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid update server base URL: %w", err)
|
|
}
|
|
|
|
target, err := securityutil.ResolveRelativeURL(baseURL, updateReleaseAPIPath())
|
|
if err != nil {
|
|
return nil, fmt.Errorf("build update release URL: %w", err)
|
|
}
|
|
|
|
return target, nil
|
|
}
|
|
|
|
func updateReleaseFeedURL() string {
|
|
return fmt.Sprintf("https://github.com/%s/releases.atom", updateReleaseRepo())
|
|
}
|
|
|
|
func resolveUpdateReleaseFeedURL() (*url.URL, error) {
|
|
target, err := securityutil.NormalizeAbsoluteHTTPURL(updateReleaseFeedURL())
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid update release feed URL: %w", err)
|
|
}
|
|
return target, nil
|
|
}
|
|
|
|
func validateApplyDownloadURL(rawURL string) (*url.URL, error) {
|
|
downloadURL, err := securityutil.NormalizeAbsoluteHTTPURL(rawURL)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid download URL: %w", err)
|
|
}
|
|
if strings.TrimSpace(os.Getenv("PULSE_UPDATE_SERVER")) != "" {
|
|
return downloadURL, nil
|
|
}
|
|
|
|
allowedPrefix, err := securityutil.NormalizeHTTPBaseURL(updateReleaseDownloadPrefix(), "")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid download URL: %w", err)
|
|
}
|
|
|
|
if downloadURL.Scheme != allowedPrefix.Scheme || !strings.EqualFold(downloadURL.Host, allowedPrefix.Host) {
|
|
return nil, fmt.Errorf("invalid download URL")
|
|
}
|
|
|
|
allowedPathPrefix := allowedPrefix.Path
|
|
if downloadURL.Path != allowedPathPrefix && !strings.HasPrefix(downloadURL.Path, allowedPathPrefix+"/") {
|
|
return nil, fmt.Errorf("invalid download URL")
|
|
}
|
|
|
|
return downloadURL, nil
|
|
}
|
|
|
|
func updateReleaseMigrationURL() string {
|
|
return fmt.Sprintf("https://github.com/%s/releases/v4.0.0", updateReleaseRepo())
|
|
}
|
|
|
|
// Manager handles update operations
|
|
type Manager struct {
|
|
config *config.Config
|
|
history *UpdateHistory
|
|
status UpdateStatus
|
|
statusMu sync.RWMutex
|
|
progressMu sync.RWMutex
|
|
updateMu sync.Mutex
|
|
updateInFlight bool
|
|
checkCache map[string]*UpdateInfo // keyed by channel
|
|
cacheTime map[string]time.Time // keyed by channel
|
|
cacheDuration time.Duration
|
|
progressChan chan UpdateStatus
|
|
sseBroadcast *SSEBroadcaster
|
|
lifecycleMu sync.RWMutex
|
|
shutdownCh chan struct{}
|
|
closeOnce sync.Once
|
|
heartbeatWg sync.WaitGroup
|
|
closed bool
|
|
// proCredentialSource lazily supplies download-broker credentials for the
|
|
// compiled Pro binary (SetProUpdateCredentialSource, wired at startup).
|
|
// Nil on the community binary.
|
|
proCredentialSource func() (ProUpdateCredentials, bool)
|
|
}
|
|
|
|
// ApplyUpdateRequest describes an update request initiated via the API/UI.
|
|
type ApplyUpdateRequest struct {
|
|
DownloadURL string
|
|
Channel string
|
|
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
|
|
func NewManager(cfg *config.Config) *Manager {
|
|
m := &Manager{
|
|
config: cfg,
|
|
checkCache: make(map[string]*UpdateInfo),
|
|
cacheTime: make(map[string]time.Time),
|
|
cacheDuration: 5 * time.Minute, // Cache update checks for 5 minutes
|
|
progressChan: make(chan UpdateStatus, 100),
|
|
sseBroadcast: NewSSEBroadcaster(),
|
|
shutdownCh: make(chan struct{}),
|
|
status: UpdateStatus{
|
|
Status: "idle",
|
|
UpdatedAt: time.Now().Format(time.RFC3339),
|
|
},
|
|
}
|
|
|
|
// Clean up old temp directories and stale update backups from previous runs.
|
|
go m.cleanupOldUpdateArtifacts()
|
|
|
|
// Start heartbeat for SSE connections (every 30 seconds)
|
|
m.heartbeatWg.Add(1)
|
|
go m.sseHeartbeatLoop()
|
|
|
|
return m
|
|
}
|
|
|
|
// SetHistory wires an update history sink for recording update progress.
|
|
func (m *Manager) SetHistory(history *UpdateHistory) {
|
|
m.history = history
|
|
}
|
|
|
|
// GetProgressChannel returns the channel for update progress
|
|
func (m *Manager) GetProgressChannel() <-chan UpdateStatus {
|
|
return m.progressChan
|
|
}
|
|
|
|
// Close closes the progress channel and cleans up resources
|
|
func (m *Manager) Close() {
|
|
m.closeOnce.Do(func() {
|
|
m.progressMu.Lock()
|
|
m.closed = true
|
|
close(m.progressChan)
|
|
m.progressMu.Unlock()
|
|
|
|
close(m.shutdownCh)
|
|
|
|
if m.sseBroadcast != nil {
|
|
m.sseBroadcast.Close()
|
|
}
|
|
|
|
m.heartbeatWg.Wait()
|
|
})
|
|
}
|
|
|
|
// GetSSEBroadcaster returns the SSE broadcaster
|
|
func (m *Manager) GetSSEBroadcaster() *SSEBroadcaster {
|
|
m.lifecycleMu.RLock()
|
|
defer m.lifecycleMu.RUnlock()
|
|
return m.sseBroadcast
|
|
}
|
|
|
|
// AddSSEClient adds a new SSE client for update progress streaming
|
|
func (m *Manager) AddSSEClient(w http.ResponseWriter, clientID string) *SSEClient {
|
|
m.lifecycleMu.RLock()
|
|
defer m.lifecycleMu.RUnlock()
|
|
if m.sseBroadcast == nil {
|
|
return nil
|
|
}
|
|
return m.sseBroadcast.AddClient(w, clientID)
|
|
}
|
|
|
|
// RemoveSSEClient removes an SSE client
|
|
func (m *Manager) RemoveSSEClient(clientID string) {
|
|
m.lifecycleMu.RLock()
|
|
defer m.lifecycleMu.RUnlock()
|
|
if m.sseBroadcast != nil {
|
|
m.sseBroadcast.RemoveClient(clientID)
|
|
}
|
|
}
|
|
|
|
// GetCachedStatus returns the last broadcasted status
|
|
func (m *Manager) GetSSECachedStatus() (UpdateStatus, time.Time) {
|
|
m.lifecycleMu.RLock()
|
|
defer m.lifecycleMu.RUnlock()
|
|
if m.sseBroadcast == nil {
|
|
return UpdateStatus{}, time.Time{}
|
|
}
|
|
return m.sseBroadcast.GetCachedStatus()
|
|
}
|
|
|
|
// CheckForUpdates checks GitHub for available updates using saved config channel
|
|
func (m *Manager) CheckForUpdates(ctx context.Context) (*UpdateInfo, error) {
|
|
return m.CheckForUpdatesWithChannel(ctx, "")
|
|
}
|
|
|
|
// CheckForUpdatesWithChannel checks GitHub for available updates with optional channel override
|
|
func (m *Manager) CheckForUpdatesWithChannel(ctx context.Context, channel string) (*UpdateInfo, error) {
|
|
// Get current version first to auto-detect channel if needed
|
|
currentInfo, err := GetCurrentVersion()
|
|
if err != nil {
|
|
m.updateStatus("error", 0, "Failed to get current version")
|
|
return nil, fmt.Errorf("failed to get current version: %w", err)
|
|
}
|
|
|
|
// Track whether an explicit channel override was provided.
|
|
explicitChannelProvided := strings.TrimSpace(channel) != ""
|
|
channel = m.resolveChannel(channel, currentInfo)
|
|
|
|
// Don't use cache when channel is explicitly provided (UI might have changed it)
|
|
// But DO use cache for auto-detected or default channels
|
|
useCache := !explicitChannelProvided
|
|
|
|
// Check cache first (only if using saved channel)
|
|
if useCache {
|
|
m.statusMu.RLock()
|
|
cachedInfo, hasCached := m.checkCache[channel]
|
|
cachedTime, hasTime := m.cacheTime[channel]
|
|
if hasCached && hasTime && time.Since(cachedTime) < m.cacheDuration {
|
|
m.statusMu.RUnlock()
|
|
return cachedInfo, nil
|
|
}
|
|
m.statusMu.RUnlock()
|
|
}
|
|
|
|
m.updateStatus("checking", 0, "Checking for updates...")
|
|
|
|
// Skip update check for source builds
|
|
if currentInfo.IsSourceBuild {
|
|
info := &UpdateInfo{
|
|
Available: false,
|
|
CurrentVersion: currentInfo.Version,
|
|
LatestVersion: currentInfo.Version,
|
|
}
|
|
if useCache {
|
|
m.statusMu.Lock()
|
|
m.checkCache[channel] = info
|
|
m.cacheTime[channel] = time.Now()
|
|
m.statusMu.Unlock()
|
|
}
|
|
m.updateStatus("idle", 0, "Updates not available for source builds")
|
|
return info, nil
|
|
}
|
|
|
|
// Parse current version first
|
|
currentVer, err := ParseVersion(currentInfo.Version)
|
|
if err != nil {
|
|
m.updateStatus("error", 0, "Invalid current version")
|
|
return nil, fmt.Errorf("failed to parse current version: %w", err)
|
|
}
|
|
|
|
// The compiled Pro binary checks the license server download broker, never
|
|
// GitHub: the public release assets are community builds, and offering one
|
|
// here would set up the silent Pro→community downgrade.
|
|
if edition.IsPro() {
|
|
info, proErr := m.checkProUpdates(ctx, channel, currentInfo, currentVer)
|
|
if proErr != nil {
|
|
m.updateStatus("error", 0, "Failed to check for Pulse Pro updates", proErr)
|
|
return nil, proErr
|
|
}
|
|
if useCache {
|
|
m.statusMu.Lock()
|
|
m.checkCache[channel] = info
|
|
m.cacheTime[channel] = time.Now()
|
|
m.statusMu.Unlock()
|
|
}
|
|
status := "idle"
|
|
message := "No updates available"
|
|
if info.Available {
|
|
status = "available"
|
|
message = fmt.Sprintf("Update available: %s", info.LatestVersion)
|
|
}
|
|
m.updateStatus(status, 100, message)
|
|
return info, nil
|
|
}
|
|
|
|
// Get latest release from GitHub with specified channel and current version
|
|
release, err := m.getLatestReleaseForChannel(ctx, channel, currentVer)
|
|
if err != nil {
|
|
if errors.Is(err, errGitHubRateLimited) {
|
|
log.Warn().Err(err).Str("channel", channel).Msg("GitHub rate limit encountered while checking for updates")
|
|
|
|
if useCache {
|
|
m.statusMu.RLock()
|
|
cachedInfo, hasCached := m.checkCache[channel]
|
|
m.statusMu.RUnlock()
|
|
if hasCached && cachedInfo != nil {
|
|
m.updateStatus("idle", 0, "Using cached update info (GitHub rate limit)")
|
|
return cachedInfo, nil
|
|
}
|
|
}
|
|
|
|
info := &UpdateInfo{
|
|
Available: false,
|
|
CurrentVersion: currentInfo.Version,
|
|
LatestVersion: currentInfo.Version,
|
|
DownloadURL: "",
|
|
IsPrerelease: currentVer.IsPrerelease(),
|
|
Warning: "Update check temporarily unavailable because GitHub rate limit was reached. Try again in a few minutes.",
|
|
}
|
|
m.updateStatus("idle", 0, "GitHub rate limit reached during update check")
|
|
return info, nil
|
|
}
|
|
|
|
// Check if this is a "no releases found" error - handle gracefully
|
|
if strings.Contains(err.Error(), "no releases found") {
|
|
// No releases available for this channel - return "no update available"
|
|
info := &UpdateInfo{
|
|
Available: false,
|
|
CurrentVersion: currentInfo.Version,
|
|
LatestVersion: currentInfo.Version,
|
|
}
|
|
if useCache {
|
|
m.statusMu.Lock()
|
|
m.checkCache[channel] = info
|
|
m.cacheTime[channel] = time.Now()
|
|
m.statusMu.Unlock()
|
|
}
|
|
m.updateStatus("idle", 0, fmt.Sprintf("No releases available for %s channel", channel))
|
|
return info, nil
|
|
}
|
|
// For other errors, return the error
|
|
m.updateStatus("error", 0, "Failed to check for updates", err)
|
|
return nil, err
|
|
}
|
|
|
|
latestVer, err := ParseVersion(release.TagName)
|
|
if err != nil {
|
|
parseErr := fmt.Errorf("failed to parse latest version: %w", err)
|
|
m.updateStatus("error", 0, "Invalid latest version", parseErr)
|
|
return nil, parseErr
|
|
}
|
|
|
|
// Find download URL for current architecture
|
|
downloadURL := ""
|
|
arch := runtime.GOARCH
|
|
// Map Go architecture names to release asset names
|
|
archMap := map[string]string{
|
|
"amd64": "amd64",
|
|
"arm64": "arm64",
|
|
"arm": "armv7",
|
|
"386": "386",
|
|
}
|
|
|
|
targetArch, ok := archMap[arch]
|
|
if !ok {
|
|
targetArch = arch // Use as-is if not in map
|
|
}
|
|
|
|
// Look for architecture-specific binary
|
|
targetName := fmt.Sprintf("pulse-%s-linux-%s.tar.gz", release.TagName, targetArch)
|
|
for _, asset := range release.Assets {
|
|
if asset.Name == targetName {
|
|
downloadURL = asset.BrowserDownloadURL
|
|
break
|
|
}
|
|
}
|
|
|
|
// Fallback to any pulse tarball if exact match not found
|
|
if downloadURL == "" {
|
|
for _, asset := range release.Assets {
|
|
if strings.HasPrefix(asset.Name, "pulse-") &&
|
|
strings.Contains(asset.Name, "linux") &&
|
|
strings.HasSuffix(asset.Name, ".tar.gz") {
|
|
downloadURL = asset.BrowserDownloadURL
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
isMajorUpgrade := latestVer.Major > currentVer.Major
|
|
// Derive prerelease from the parsed version tag (not GitHub metadata) so the
|
|
// warning is correct even if the release was published with prerelease=false.
|
|
isPrerelease := release.Prerelease || latestVer.IsPrerelease()
|
|
|
|
info := &UpdateInfo{
|
|
Available: latestVer.IsNewerThan(currentVer),
|
|
CurrentVersion: currentInfo.Version,
|
|
LatestVersion: strings.TrimPrefix(release.TagName, "v"),
|
|
ReleaseNotes: release.Body,
|
|
ReleaseDate: release.PublishedAt,
|
|
DownloadURL: downloadURL,
|
|
IsPrerelease: isPrerelease,
|
|
IsMajorUpgrade: isMajorUpgrade,
|
|
}
|
|
|
|
info.Warning = updateWarning(info.Available, isMajorUpgrade, isPrerelease, currentVer.Major, latestVer.Major)
|
|
|
|
// Cache the result (only if using saved channel)
|
|
if useCache {
|
|
m.statusMu.Lock()
|
|
m.checkCache[channel] = info
|
|
m.cacheTime[channel] = time.Now()
|
|
m.statusMu.Unlock()
|
|
}
|
|
|
|
status := "idle"
|
|
message := "No updates available"
|
|
if info.Available {
|
|
status = "available"
|
|
message = fmt.Sprintf("Update available: %s", info.LatestVersion)
|
|
}
|
|
m.updateStatus(status, 100, message)
|
|
|
|
return info, nil
|
|
}
|
|
|
|
// updateWarning derives the user-facing caution attached to an available
|
|
// update. Shared by the community (GitHub) and Pro (download broker) checks.
|
|
func updateWarning(available, isMajorUpgrade, isPrerelease bool, currentMajor, latestMajor int) string {
|
|
switch {
|
|
case available && isMajorUpgrade && isPrerelease:
|
|
return fmt.Sprintf(
|
|
"This is a major version upgrade (v%d → v%d) and a pre-release build. "+
|
|
"We strongly recommend installing this as a separate instance rather than upgrading your production installation. "+
|
|
"Pre-release builds may contain bugs and are intended for testing.",
|
|
currentMajor, latestMajor,
|
|
)
|
|
case available && isMajorUpgrade:
|
|
return fmt.Sprintf(
|
|
"This is a major version upgrade (v%d → v%d). Please review the release notes carefully before updating.",
|
|
currentMajor, latestMajor,
|
|
)
|
|
case available && isPrerelease:
|
|
return "This is a pre-release build. Pre-release builds are tested but may have rough edges."
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
// ApplyUpdate downloads and applies an update
|
|
func (m *Manager) ApplyUpdate(ctx context.Context, req ApplyUpdateRequest) error {
|
|
if req.DownloadURL == "" {
|
|
return fmt.Errorf("download URL is required")
|
|
}
|
|
|
|
// The separately compiled Pulse Pro binary must never install the public
|
|
// community build (it would silently strip Audit, RBAC, Reporting, and
|
|
// SSO), so it updates through the license server download broker instead.
|
|
// This keys off the compiled edition, not license state: a community
|
|
// binary with an active license is still community and updates normally.
|
|
isPro := edition.IsPro()
|
|
var validatedDownloadURL *url.URL
|
|
if isPro {
|
|
creds, ok := m.proUpdateCredentials()
|
|
if !ok {
|
|
return errProUpdateNotActivated()
|
|
}
|
|
if err := validateProApplyRequestURL(req.DownloadURL, creds.LicenseServerURL); err != nil {
|
|
return err
|
|
}
|
|
} else {
|
|
var validationErr error
|
|
validatedDownloadURL, validationErr = validateApplyDownloadURL(req.DownloadURL)
|
|
if validationErr != nil {
|
|
return validationErr
|
|
}
|
|
req.DownloadURL = validatedDownloadURL.String()
|
|
}
|
|
|
|
// Check if Docker
|
|
currentInfo, _ := GetCurrentVersion()
|
|
if currentInfo.IsDocker {
|
|
return fmt.Errorf("updates cannot be applied in Docker environment")
|
|
}
|
|
|
|
// Check for pre-v4 installation
|
|
if isPreV4Installation() {
|
|
return fmt.Errorf("manual migration required: Pulse v4 is a complete rewrite. Please create a fresh installation. See %s", updateReleaseMigrationURL())
|
|
}
|
|
|
|
// Ensure only one update runs at a time.
|
|
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()
|
|
}()
|
|
|
|
m.updateStatus("downloading", 10, "Downloading update...")
|
|
|
|
channel := m.resolveChannel(req.Channel, currentInfo)
|
|
var artifact resolvedUpdateArtifact
|
|
if isPro {
|
|
// Resolve fresh signed URLs from the broker at apply time: the URLs it
|
|
// hands out expire in minutes, so the check-time response is never
|
|
// reused here.
|
|
var resolveErr error
|
|
artifact, resolveErr = m.resolveProUpdateArtifact(ctx, channel)
|
|
if resolveErr != nil {
|
|
m.updateStatus("error", 10, "Failed to resolve Pulse Pro update", resolveErr)
|
|
return resolveErr
|
|
}
|
|
} 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
|
|
}
|
|
initiatedVia := req.InitiatedVia
|
|
if initiatedVia == "" {
|
|
initiatedVia = InitiatedViaAPI
|
|
}
|
|
|
|
start := time.Now()
|
|
eventID := m.createHistoryEntry(ctx, UpdateHistoryEntry{
|
|
Action: "update",
|
|
Channel: channel,
|
|
VersionFrom: currentInfo.Version,
|
|
VersionTo: artifact.version,
|
|
DeploymentType: currentInfo.DeploymentType,
|
|
InitiatedBy: initiatedBy,
|
|
InitiatedVia: initiatedVia,
|
|
Status: StatusInProgress,
|
|
Notes: req.Notes,
|
|
})
|
|
|
|
var runErr error
|
|
defer func() {
|
|
if eventID == "" {
|
|
return
|
|
}
|
|
status := StatusSuccess
|
|
if runErr != nil {
|
|
status = StatusFailed
|
|
}
|
|
m.completeHistoryEntry(ctx, eventID, status, start, runErr)
|
|
}()
|
|
|
|
tempDir, err := m.createUpdateTempDir(ctx, minUpdateTempFreeBytes)
|
|
if err != nil {
|
|
tempErr := fmt.Errorf("failed to create temp directory: %w", err)
|
|
m.updateStatus("error", 10, "Failed to create temp directory", tempErr)
|
|
runErr = tempErr
|
|
return tempErr
|
|
}
|
|
defer os.RemoveAll(tempDir)
|
|
|
|
// Download update
|
|
tarballPath := filepath.Join(tempDir, "update.tar.gz")
|
|
downloadBytes, err := m.downloadFile(ctx, artifact.downloadURL, tarballPath)
|
|
if err != nil {
|
|
downloadErr := fmt.Errorf("failed to download update: %w", err)
|
|
m.updateStatus("error", 20, "Failed to download update", downloadErr)
|
|
runErr = downloadErr
|
|
return runErr
|
|
}
|
|
if downloadBytes > 0 {
|
|
m.updateHistoryEntry(ctx, eventID, func(entry *UpdateHistoryEntry) {
|
|
entry.DownloadBytes = downloadBytes
|
|
})
|
|
}
|
|
|
|
// Verify SSHSIG signature against the pinned pulse-installer key. This is
|
|
// the same trust root scripts/pulse-auto-update.sh and scripts/install.sh
|
|
// already enforce; the in-app updater must not run at a lower bar. The Pro
|
|
// broker hands out an explicit signed sidecar URL; the community path
|
|
// derives it from the asset URL.
|
|
m.updateStatus("verifying", 25, "Verifying signature...")
|
|
if artifact.sshsigURL != "" {
|
|
err = m.downloadAndVerifySignatureFromURL(ctx, artifact.sshsigURL, tarballPath)
|
|
} else {
|
|
err = m.downloadAndVerifyReleaseSignature(ctx, validatedDownloadURL, tarballPath)
|
|
}
|
|
if err != nil {
|
|
sigErr := fmt.Errorf("signature verification failed: %w", err)
|
|
m.updateStatus("error", 25, "Failed to verify update signature", sigErr)
|
|
runErr = sigErr
|
|
return runErr
|
|
}
|
|
log.Info().Msg("Signature verification passed")
|
|
|
|
// Verify checksum: the Pro broker manifest carries the expected sha256
|
|
// inline; the community path discovers a SHA256SUMS manifest next to the
|
|
// release asset.
|
|
m.updateStatus("verifying", 30, "Verifying download...")
|
|
if artifact.sha256 != "" {
|
|
err = verifyFileSHA256(tarballPath, artifact.sha256)
|
|
} else {
|
|
err = m.verifyChecksum(ctx, artifact.downloadURL, tarballPath)
|
|
}
|
|
if err != nil {
|
|
checksumErr := fmt.Errorf("checksum verification failed: %w", err)
|
|
m.updateStatus("error", 30, "Failed to verify update checksum", checksumErr)
|
|
runErr = checksumErr
|
|
return runErr
|
|
}
|
|
log.Info().Msg("Checksum verification passed")
|
|
|
|
m.updateStatus("extracting", 40, "Extracting update...")
|
|
|
|
extractBytes, err := estimateTarballExtractBytes(tarballPath)
|
|
if err != nil {
|
|
extractErr := fmt.Errorf("failed to size update archive: %w", err)
|
|
m.updateStatus("error", 40, "Failed to prepare update extraction", extractErr)
|
|
runErr = extractErr
|
|
return runErr
|
|
}
|
|
if err := ensureUpdatePathHasFreeSpace(ctx, tempDir, extractBytes+updateExtractSafetyBytes, "extract update archive"); err != nil {
|
|
extractErr := fmt.Errorf("insufficient disk space for update extraction: %w", err)
|
|
m.updateStatus("error", 40, "Not enough disk space to extract update", extractErr)
|
|
runErr = extractErr
|
|
return runErr
|
|
}
|
|
|
|
// Extract tarball
|
|
extractDir := filepath.Join(tempDir, "extracted")
|
|
if err := m.extractTarball(tarballPath, extractDir); err != nil {
|
|
extractErr := fmt.Errorf("failed to extract update: %w", err)
|
|
m.updateStatus("error", 40, "Failed to extract update", extractErr)
|
|
runErr = extractErr
|
|
return runErr
|
|
}
|
|
|
|
m.updateStatus("verifying", 50, "Validating new binary...")
|
|
|
|
// Checksum and signature prove download integrity, not that the binary can
|
|
// run on this host or is the approved version. Probe it before anything is
|
|
// backed up or replaced, so a bad artifact fails with zero changes applied.
|
|
newBinary, err := locateExtractedPulseBinary(extractDir)
|
|
if err != nil {
|
|
m.updateStatus("error", 50, "Update package is missing the pulse binary", err)
|
|
runErr = err
|
|
return runErr
|
|
}
|
|
if err := selfTestNewBinary(ctx, newBinary, extractDir, artifact.version); err != nil {
|
|
selfTestErr := fmt.Errorf("new binary failed pre-install validation: %w", err)
|
|
m.updateStatus("error", 50, "New binary failed pre-install validation", selfTestErr)
|
|
runErr = selfTestErr
|
|
return runErr
|
|
}
|
|
log.Info().Str("version", artifact.version).Msg("New binary passed pre-install self-test")
|
|
|
|
m.updateStatus("backing-up", 60, "Creating backup...")
|
|
|
|
// Create backup
|
|
backupPath, err := m.createBackup(ctx)
|
|
if err != nil {
|
|
backupErr := fmt.Errorf("failed to create backup: %w", err)
|
|
m.updateStatus("error", 60, "Failed to create backup", backupErr)
|
|
runErr = backupErr
|
|
return runErr
|
|
}
|
|
log.Info().Str("backup", backupPath).Msg("Created backup")
|
|
m.updateHistoryEntry(ctx, eventID, func(entry *UpdateHistoryEntry) {
|
|
entry.BackupPath = backupPath
|
|
})
|
|
|
|
m.updateStatus("applying", 80, "Applying update...")
|
|
|
|
// Apply the update files
|
|
// With the new directory structure (/opt/pulse/bin/), the pulse user has write access
|
|
log.Info().Msg("Applying update files")
|
|
|
|
if err := m.applyUpdateFiles(extractDir); err != nil {
|
|
applyErr := fmt.Errorf("failed to apply update: %w", err)
|
|
m.updateStatus("error", 80, "Failed to apply update", applyErr)
|
|
runErr = applyErr
|
|
// Attempt to restore backup
|
|
if restoreErr := m.restoreBackup(backupPath); restoreErr != nil {
|
|
log.Error().Err(restoreErr).Msg("Failed to restore backup")
|
|
}
|
|
return runErr
|
|
}
|
|
|
|
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 update")
|
|
os.Exit(0)
|
|
}()
|
|
} else {
|
|
log.Info().Msg("Skipping process exit after update (mock/CI mode)")
|
|
}
|
|
|
|
m.updateStatus("completed", 100, "Update completed, restarting...")
|
|
return nil
|
|
}
|
|
|
|
// GetStatus returns the current update status
|
|
func (m *Manager) GetStatus() UpdateStatus {
|
|
m.statusMu.RLock()
|
|
defer m.statusMu.RUnlock()
|
|
return m.status
|
|
}
|
|
|
|
// GetCachedUpdateInfo returns the cached update info without making a network request
|
|
// Returns nil if no cached info is available
|
|
// Uses the configured or auto-detected channel
|
|
func (m *Manager) GetCachedUpdateInfo() *UpdateInfo {
|
|
currentInfo, _ := GetCurrentVersion()
|
|
channel := m.resolveChannel("", currentInfo)
|
|
|
|
m.statusMu.RLock()
|
|
defer m.statusMu.RUnlock()
|
|
return m.checkCache[channel]
|
|
}
|
|
|
|
// getLatestReleaseForChannel fetches the latest release from GitHub for a specific channel
|
|
func (m *Manager) getLatestReleaseForChannel(ctx context.Context, channel string, currentVer *Version) (*ReleaseInfo, error) {
|
|
if channel == "" {
|
|
channel = "stable"
|
|
}
|
|
|
|
log.Info().
|
|
Str("channel", channel).
|
|
Str("currentVersion", currentVer.String()).
|
|
Bool("isPrerelease", currentVer.IsPrerelease()).
|
|
Msg("Checking for updates")
|
|
|
|
releasesURL, err := resolveUpdateReleaseAPIURL()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("resolve releases request for channel %q: %w", channel, err)
|
|
}
|
|
|
|
client := &http.Client{Timeout: 30 * time.Second}
|
|
resp, err := m.getWithRetry(ctx, client, releasesURL, map[string]string{
|
|
"Accept": "application/vnd.github.v3+json",
|
|
"User-Agent": "Pulse-Update-Checker",
|
|
}, "fetch GitHub releases")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to fetch releases: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode == http.StatusForbidden {
|
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
|
log.Warn().
|
|
Str("channel", channel).
|
|
Str("rateLimitRemaining", resp.Header.Get("X-RateLimit-Remaining")).
|
|
Str("rateLimitReset", resp.Header.Get("X-RateLimit-Reset")).
|
|
Msg("GitHub API rate limit encountered, trying RSS fallback")
|
|
|
|
// Try RSS/Atom feed as fallback - doesn't count against rate limits
|
|
if feedRelease, err := m.getLatestReleaseFromFeed(ctx, channel); err == nil {
|
|
log.Info().Str("version", feedRelease.TagName).Msg("Got release info from RSS feed fallback")
|
|
return feedRelease, nil
|
|
}
|
|
|
|
detail := strings.TrimSpace(string(body))
|
|
if detail == "" {
|
|
detail = resp.Status
|
|
}
|
|
|
|
return nil, fmt.Errorf("%w: %s", errGitHubRateLimited, detail)
|
|
}
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
|
detail := strings.TrimSpace(string(body))
|
|
if detail == "" {
|
|
detail = resp.Status
|
|
}
|
|
return nil, fmt.Errorf("GitHub API returned status %d: %s", resp.StatusCode, detail)
|
|
}
|
|
|
|
var releases []ReleaseInfo
|
|
if err := json.NewDecoder(resp.Body).Decode(&releases); err != nil {
|
|
return nil, fmt.Errorf("failed to decode releases: %w", err)
|
|
}
|
|
|
|
// Find latest release based on channel
|
|
// Prerelease channel: return newest release (prerelease or stable), even if not newer than current
|
|
// Stable channel: return newest stable release, even if not newer than current
|
|
// The caller will determine if it's actually an update by comparing versions
|
|
if channel == "rc" {
|
|
// For the prerelease channel: find newest release (prerelease or stable)
|
|
// Prerelease users should see both prereleases and stable releases
|
|
var newestRC *ReleaseInfo
|
|
var newestStable *ReleaseInfo
|
|
|
|
for i := range releases {
|
|
// Skip draft releases
|
|
if releases[i].Draft {
|
|
log.Debug().Str("tag", releases[i].TagName).Msg("Skipping draft release")
|
|
continue
|
|
}
|
|
|
|
releaseVer, err := ParseVersion(releases[i].TagName)
|
|
if err != nil {
|
|
log.Debug().Str("tag", releases[i].TagName).Err(err).Msg("Failed to parse release version")
|
|
continue
|
|
}
|
|
|
|
if releases[i].Prerelease {
|
|
// Track newest prerelease
|
|
if newestRC == nil {
|
|
newestRC = &releases[i]
|
|
} else {
|
|
newestRCVer, _ := ParseVersion(newestRC.TagName)
|
|
if releaseVer.IsNewerThan(newestRCVer) {
|
|
newestRC = &releases[i]
|
|
}
|
|
}
|
|
} else {
|
|
// Track newest stable
|
|
if newestStable == nil {
|
|
newestStable = &releases[i]
|
|
} else {
|
|
newestStableVer, _ := ParseVersion(newestStable.TagName)
|
|
if releaseVer.IsNewerThan(newestStableVer) {
|
|
newestStable = &releases[i]
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Return the highest version among candidates
|
|
// Stable versions are considered higher than RCs (4.22.0 > 4.22.0-rc.3)
|
|
if newestStable != nil && newestRC != nil {
|
|
stableVer, _ := ParseVersion(newestStable.TagName)
|
|
rcVer, _ := ParseVersion(newestRC.TagName)
|
|
if stableVer.IsNewerThan(rcVer) {
|
|
isUpdate := stableVer.IsNewerThan(currentVer)
|
|
if isUpdate {
|
|
log.Info().Str("version", newestStable.TagName).Msg("Found stable update for prerelease user")
|
|
} else {
|
|
log.Info().Str("version", newestStable.TagName).Msg("On latest stable version")
|
|
}
|
|
return newestStable, nil
|
|
}
|
|
isUpdate := rcVer.IsNewerThan(currentVer)
|
|
if isUpdate {
|
|
log.Info().Str("version", newestRC.TagName).Msg("Found prerelease update")
|
|
} else {
|
|
log.Info().Str("version", newestRC.TagName).Msg("On latest prerelease version")
|
|
}
|
|
return newestRC, nil
|
|
} else if newestStable != nil {
|
|
isUpdate := newestStable.TagName != currentVer.String()
|
|
if isUpdate {
|
|
log.Info().Str("version", newestStable.TagName).Msg("Found stable update for prerelease user")
|
|
} else {
|
|
log.Info().Str("version", newestStable.TagName).Msg("On latest stable version")
|
|
}
|
|
return newestStable, nil
|
|
} else if newestRC != nil {
|
|
isUpdate := newestRC.TagName != currentVer.String()
|
|
if isUpdate {
|
|
log.Info().Str("version", newestRC.TagName).Msg("Found prerelease update")
|
|
} else {
|
|
log.Info().Str("version", newestRC.TagName).Msg("On latest prerelease version")
|
|
}
|
|
return newestRC, nil
|
|
}
|
|
} else {
|
|
// For stable channel: find latest non-prerelease
|
|
for i := range releases {
|
|
// Skip draft releases
|
|
if releases[i].Draft {
|
|
log.Debug().Str("tag", releases[i].TagName).Msg("Skipping draft release")
|
|
continue
|
|
}
|
|
|
|
if releases[i].Prerelease {
|
|
continue
|
|
}
|
|
|
|
releaseVer, err := ParseVersion(releases[i].TagName)
|
|
if err != nil {
|
|
log.Debug().Str("tag", releases[i].TagName).Err(err).Msg("Failed to parse release version")
|
|
continue
|
|
}
|
|
|
|
// Also skip if the version tag itself indicates a prerelease
|
|
// (guards against GitHub metadata being set incorrectly)
|
|
if releaseVer.IsPrerelease() {
|
|
log.Debug().Str("tag", releases[i].TagName).Msg("Skipping release with prerelease version tag on stable channel")
|
|
continue
|
|
}
|
|
|
|
// Found the latest stable release
|
|
isUpdate := releaseVer.IsNewerThan(currentVer)
|
|
if isUpdate {
|
|
log.Info().Str("version", releases[i].TagName).Msg("Found stable update")
|
|
} else {
|
|
log.Info().Str("version", releases[i].TagName).Msg("On latest stable version")
|
|
}
|
|
return &releases[i], nil
|
|
}
|
|
}
|
|
|
|
// No releases found at all for this channel
|
|
log.Warn().Str("channel", channel).Msg("No releases found for channel")
|
|
return nil, fmt.Errorf("no releases found for channel %s", channel)
|
|
}
|
|
|
|
func (m *Manager) resolveChannel(requested string, currentInfo *VersionInfo) string {
|
|
if canonical, ok := config.CanonicalUpdateChannel(requested); ok {
|
|
return canonical
|
|
}
|
|
if m.config != nil {
|
|
if canonical, ok := config.CanonicalUpdateChannel(m.config.UpdateChannel); ok {
|
|
return canonical
|
|
}
|
|
}
|
|
if currentInfo != nil {
|
|
if canonical, ok := config.CanonicalUpdateChannel(currentInfo.Channel); ok {
|
|
return canonical
|
|
}
|
|
}
|
|
return "stable"
|
|
}
|
|
|
|
// getLatestReleaseFromFeed fetches the latest release from GitHub's Atom feed
|
|
// This is used as a fallback when the API is rate-limited, as the Atom feed
|
|
// doesn't count against API rate limits.
|
|
func (m *Manager) getLatestReleaseFromFeed(ctx context.Context, channel string) (*ReleaseInfo, error) {
|
|
feedURL, err := resolveUpdateReleaseFeedURL()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("resolve release feed URL: %w", err)
|
|
}
|
|
|
|
client := &http.Client{Timeout: 30 * time.Second}
|
|
resp, err := m.getWithRetry(ctx, client, feedURL, map[string]string{
|
|
"User-Agent": "Pulse-Update-Checker",
|
|
}, "fetch GitHub release feed")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to fetch feed: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("feed returned status %d", resp.StatusCode)
|
|
}
|
|
|
|
if resp.ContentLength > maxReleaseFeedBytes {
|
|
return nil, fmt.Errorf("feed response exceeds %d bytes", maxReleaseFeedBytes)
|
|
}
|
|
|
|
body, err := io.ReadAll(io.LimitReader(resp.Body, maxReleaseFeedBytes+1))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to read feed: %w", err)
|
|
}
|
|
if int64(len(body)) > maxReleaseFeedBytes {
|
|
return nil, fmt.Errorf("feed response exceeds %d bytes", maxReleaseFeedBytes)
|
|
}
|
|
|
|
// Parse the Atom feed to extract version tags
|
|
// The feed format includes entries like: <title>Pulse v5.0.0</title>
|
|
// We use simple string parsing rather than a full XML parser for minimal deps
|
|
content := string(body)
|
|
|
|
// Find all version tags in the feed (format: "Pulse vX.Y.Z" or "Pulse vX.Y.Z-rc.N")
|
|
versionRegex := regexp.MustCompile(`<title>Pulse (v\d+\.\d+\.\d+(?:-[a-zA-Z0-9.]+)?)</title>`)
|
|
matches := versionRegex.FindAllStringSubmatch(content, -1)
|
|
|
|
if len(matches) == 0 {
|
|
return nil, fmt.Errorf("no version tags found in feed")
|
|
}
|
|
|
|
// Filter based on channel
|
|
for _, match := range matches {
|
|
if len(match) < 2 {
|
|
continue
|
|
}
|
|
tagName := match[1]
|
|
|
|
// Parse the version to check if it's a prerelease
|
|
ver, err := ParseVersion(tagName)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
isPrerelease := ver.IsPrerelease()
|
|
|
|
// For stable channel, skip prereleases
|
|
if channel == "stable" && isPrerelease {
|
|
continue
|
|
}
|
|
|
|
// Found a valid release for this channel
|
|
log.Debug().
|
|
Str("tag", tagName).
|
|
Bool("prerelease", isPrerelease).
|
|
Str("channel", channel).
|
|
Msg("Found release from feed")
|
|
|
|
return &ReleaseInfo{
|
|
TagName: tagName,
|
|
Name: "Pulse " + tagName,
|
|
Prerelease: isPrerelease,
|
|
// Note: Feed doesn't include full release notes or asset info
|
|
// This is just for version checking - actual download still uses known URL patterns
|
|
}, nil
|
|
}
|
|
|
|
return nil, fmt.Errorf("no suitable release found for channel %s", channel)
|
|
}
|
|
|
|
func (m *Manager) createHistoryEntry(ctx context.Context, entry UpdateHistoryEntry) string {
|
|
if m.history == nil {
|
|
return ""
|
|
}
|
|
eventID, err := m.history.CreateEntry(ctx, entry)
|
|
if err != nil {
|
|
log.Error().Err(err).Msg("Failed to create update history entry")
|
|
return ""
|
|
}
|
|
return eventID
|
|
}
|
|
|
|
func (m *Manager) updateHistoryEntry(ctx context.Context, eventID string, updateFn func(entry *UpdateHistoryEntry)) {
|
|
if m.history == nil || eventID == "" {
|
|
return
|
|
}
|
|
if err := m.history.UpdateEntry(ctx, eventID, func(e *UpdateHistoryEntry) error {
|
|
updateFn(e)
|
|
return nil
|
|
}); err != nil {
|
|
log.Error().Err(err).Str("event_id", eventID).Msg("Failed to update history entry")
|
|
}
|
|
}
|
|
|
|
func (m *Manager) completeHistoryEntry(ctx context.Context, eventID string, status UpdateStatusType, start time.Time, runErr error) {
|
|
if m.history == nil || eventID == "" {
|
|
return
|
|
}
|
|
if err := m.history.UpdateEntry(ctx, eventID, func(e *UpdateHistoryEntry) error {
|
|
e.Status = status
|
|
e.DurationMs = time.Since(start).Milliseconds()
|
|
if runErr != nil {
|
|
e.Error = &UpdateError{
|
|
Message: runErr.Error(),
|
|
Code: "update_failed",
|
|
}
|
|
} else {
|
|
e.Error = nil
|
|
}
|
|
return nil
|
|
}); err != nil {
|
|
log.Error().Err(err).Str("event_id", eventID).Msg("Failed to finalize history entry")
|
|
}
|
|
}
|
|
|
|
var versionInURLRegex = regexp.MustCompile(`v\d+\.\d+\.\d+(?:-[A-Za-z0-9\.]*\d[A-Za-z0-9\.]*)?`)
|
|
|
|
func inferVersionFromDownloadURL(downloadURL string) string {
|
|
if downloadURL == "" {
|
|
return ""
|
|
}
|
|
if parsed, err := securityutil.NormalizeAbsoluteHTTPURL(downloadURL); err == nil {
|
|
if match := versionInURLRegex.FindString(parsed.Path); match != "" {
|
|
return match
|
|
}
|
|
if match := versionInURLRegex.FindString(filepath.Base(parsed.Path)); match != "" {
|
|
return match
|
|
}
|
|
}
|
|
if match := versionInURLRegex.FindString(downloadURL); match != "" {
|
|
return match
|
|
}
|
|
if match := versionInURLRegex.FindString(filepath.Base(downloadURL)); match != "" {
|
|
return match
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// ValidateApplyTargetVersion extracts and validates the target release from an
|
|
// update download URL using the same channel rules enforced by the updater.
|
|
func ValidateApplyTargetVersion(channel string, downloadURL string) (string, error) {
|
|
targetVersion := inferVersionFromDownloadURL(downloadURL)
|
|
if targetVersion == "" {
|
|
return "", fmt.Errorf("invalid download URL")
|
|
}
|
|
|
|
targetVer, err := ParseVersion(targetVersion)
|
|
if err != nil {
|
|
return "", fmt.Errorf("invalid download URL")
|
|
}
|
|
if channel == "stable" && targetVer.IsPrerelease() {
|
|
return "", fmt.Errorf("stable channel cannot install prerelease builds")
|
|
}
|
|
|
|
return targetVersion, nil
|
|
}
|
|
|
|
func isRetryableUpdateStatusCode(statusCode int) bool {
|
|
return statusCode == http.StatusRequestTimeout ||
|
|
statusCode == http.StatusTooManyRequests ||
|
|
statusCode >= http.StatusInternalServerError
|
|
}
|
|
|
|
func isRetryableUpdateRequestError(err error) bool {
|
|
if err == nil {
|
|
return false
|
|
}
|
|
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
|
return false
|
|
}
|
|
|
|
var netErr net.Error
|
|
if errors.As(err, &netErr) {
|
|
return true
|
|
}
|
|
if errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) {
|
|
return true
|
|
}
|
|
|
|
msg := strings.ToLower(err.Error())
|
|
retryableFragments := []string{
|
|
"connection reset",
|
|
"connection refused",
|
|
"connection aborted",
|
|
"broken pipe",
|
|
"temporary failure",
|
|
"timeout",
|
|
"tls handshake timeout",
|
|
"http2: server sent goaway",
|
|
}
|
|
for _, fragment := range retryableFragments {
|
|
if strings.Contains(msg, fragment) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func retryDelayForAttempt(attempt int) time.Duration {
|
|
if attempt <= 0 {
|
|
return updateHTTPBackoff
|
|
}
|
|
|
|
delay := updateHTTPBackoff
|
|
for i := 1; i < attempt; i++ {
|
|
delay *= 2
|
|
if delay >= updateHTTPMaxBackoff {
|
|
return updateHTTPMaxBackoff
|
|
}
|
|
}
|
|
if delay > updateHTTPMaxBackoff {
|
|
return updateHTTPMaxBackoff
|
|
}
|
|
return delay
|
|
}
|
|
|
|
func sleepWithContext(ctx context.Context, delay time.Duration) error {
|
|
timer := time.NewTimer(delay)
|
|
defer timer.Stop()
|
|
|
|
select {
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
case <-timer.C:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func (m *Manager) getWithRetry(ctx context.Context, client *http.Client, target *url.URL, headers map[string]string, operation string) (*http.Response, error) {
|
|
if client == nil {
|
|
client = &http.Client{Timeout: 30 * time.Second}
|
|
}
|
|
if target == nil {
|
|
return nil, fmt.Errorf("target URL is required")
|
|
}
|
|
|
|
if updateHTTPAttempts < 1 {
|
|
updateHTTPAttempts = 1
|
|
}
|
|
|
|
targetURL := target.String()
|
|
for attempt := 1; attempt <= updateHTTPAttempts; attempt++ {
|
|
req, err := securityutil.NewValidatedRequestWithContext(ctx, http.MethodGet, target, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for key, value := range headers {
|
|
req.Header.Set(key, value)
|
|
}
|
|
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
if !isRetryableUpdateRequestError(err) || attempt == updateHTTPAttempts {
|
|
return nil, err
|
|
}
|
|
delay := retryDelayForAttempt(attempt)
|
|
log.Warn().
|
|
Err(err).
|
|
Int("attempt", attempt).
|
|
Int("maxAttempts", updateHTTPAttempts).
|
|
Dur("retryIn", delay).
|
|
Str("operation", operation).
|
|
Str("url", targetURL).
|
|
Msg("Transient update request error; retrying")
|
|
if err := sleepWithContext(ctx, delay); err != nil {
|
|
return nil, err
|
|
}
|
|
continue
|
|
}
|
|
|
|
if isRetryableUpdateStatusCode(resp.StatusCode) && attempt < updateHTTPAttempts {
|
|
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 4096))
|
|
resp.Body.Close()
|
|
|
|
delay := retryDelayForAttempt(attempt)
|
|
log.Warn().
|
|
Int("statusCode", resp.StatusCode).
|
|
Int("attempt", attempt).
|
|
Int("maxAttempts", updateHTTPAttempts).
|
|
Dur("retryIn", delay).
|
|
Str("operation", operation).
|
|
Str("url", targetURL).
|
|
Msg("Transient update HTTP status; retrying")
|
|
|
|
if err := sleepWithContext(ctx, delay); err != nil {
|
|
return nil, err
|
|
}
|
|
continue
|
|
}
|
|
|
|
return resp, nil
|
|
}
|
|
|
|
return nil, fmt.Errorf("%s failed after retries", operation)
|
|
}
|
|
|
|
// downloadFile downloads a file from URL to dest
|
|
func (m *Manager) downloadFile(ctx context.Context, rawURL, dest string) (int64, error) {
|
|
downloadURL, err := securityutil.NormalizeAbsoluteHTTPURL(rawURL)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("download %q: %w", rawURL, err)
|
|
}
|
|
|
|
client := &http.Client{Timeout: 5 * time.Minute}
|
|
resp, err := m.getWithRetry(ctx, client, downloadURL, nil, "download file")
|
|
if err != nil {
|
|
return 0, fmt.Errorf("download %q: %w", rawURL, err)
|
|
}
|
|
defer func() {
|
|
if closeErr := resp.Body.Close(); closeErr != nil {
|
|
log.Warn().Err(closeErr).Str("url", downloadURL.String()).Msg("Failed to close download response body")
|
|
}
|
|
}()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return 0, fmt.Errorf("download %q: status %d", rawURL, resp.StatusCode)
|
|
}
|
|
if updateHTTPAttempts < 1 {
|
|
updateHTTPAttempts = 1
|
|
}
|
|
if resp.ContentLength > maxUpdateDownloadBytes {
|
|
return 0, fmt.Errorf("download exceeds maximum size of %d bytes", maxUpdateDownloadBytes)
|
|
}
|
|
|
|
out, err := os.Create(dest)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("create download destination %q: %w", dest, err)
|
|
}
|
|
defer func() {
|
|
if closeErr := out.Close(); closeErr != nil {
|
|
log.Warn().Err(closeErr).Str("path", dest).Msg("Failed to close downloaded file")
|
|
}
|
|
}()
|
|
|
|
// Copy with progress updates
|
|
written, err := io.Copy(out, io.LimitReader(resp.Body, maxUpdateDownloadBytes+1))
|
|
if err != nil {
|
|
return 0, fmt.Errorf("copy download response to %q: %w", dest, err)
|
|
}
|
|
if written > maxUpdateDownloadBytes {
|
|
_ = out.Close()
|
|
_ = os.Remove(dest)
|
|
return 0, fmt.Errorf("download exceeds maximum size of %d bytes", maxUpdateDownloadBytes)
|
|
}
|
|
|
|
return written, nil
|
|
}
|
|
|
|
// verifyChecksum downloads and verifies the SHA256 checksum of a file
|
|
func (m *Manager) verifyChecksum(ctx context.Context, tarballURL, tarballPath string) error {
|
|
tarballTarget, err := securityutil.NormalizeAbsoluteHTTPURL(tarballURL)
|
|
if err != nil {
|
|
return fmt.Errorf("invalid tarball URL %q: %w", tarballURL, err)
|
|
}
|
|
|
|
// Common checksum file names used in GitHub releases
|
|
checksumNames := []string{"SHA256SUMS", "checksums.txt", "SHA256SUMS.txt"}
|
|
|
|
var checksumContent string
|
|
var checksumErr error
|
|
|
|
client := &http.Client{Timeout: 30 * time.Second}
|
|
|
|
// Try each checksum filename
|
|
for _, name := range checksumNames {
|
|
checksumURL := tarballTarget.ResolveReference(&url.URL{Path: name})
|
|
|
|
resp, err := m.getWithRetry(ctx, client, checksumURL, nil, "download checksum manifest")
|
|
if err != nil {
|
|
log.Debug().Err(err).Str("url", checksumURL.String()).Msg("Failed to create checksum request")
|
|
continue
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode == http.StatusOK {
|
|
if resp.ContentLength > maxChecksumFileBytes {
|
|
checksumErr = fmt.Errorf("checksum file %s exceeds %d bytes", name, maxChecksumFileBytes)
|
|
continue
|
|
}
|
|
|
|
body, err := io.ReadAll(io.LimitReader(resp.Body, maxChecksumFileBytes+1))
|
|
if err != nil {
|
|
checksumErr = fmt.Errorf("failed to read checksum file %s: %w", name, err)
|
|
continue
|
|
}
|
|
if int64(len(body)) > maxChecksumFileBytes {
|
|
checksumErr = fmt.Errorf("checksum file %s exceeds %d bytes", name, maxChecksumFileBytes)
|
|
continue
|
|
}
|
|
|
|
checksumContent = string(body)
|
|
log.Info().Str("file", name).Msg("Found checksum file")
|
|
break
|
|
}
|
|
|
|
log.Debug().Int("status", resp.StatusCode).Str("url", checksumURL.String()).Msg("Non-OK checksum response, trying next")
|
|
resp.Body.Close()
|
|
}
|
|
|
|
if checksumContent == "" {
|
|
if checksumErr != nil {
|
|
return checksumErr
|
|
}
|
|
return fmt.Errorf("no checksum file found")
|
|
}
|
|
|
|
// Parse checksum file to find the hash for our tarball
|
|
tarballName := filepath.Base(tarballURL)
|
|
expectedHash := ""
|
|
|
|
for _, line := range strings.Split(checksumContent, "\n") {
|
|
line = strings.TrimSpace(line)
|
|
if line == "" {
|
|
continue
|
|
}
|
|
|
|
// Format: "hash filename" or "hash *filename"
|
|
parts := strings.Fields(line)
|
|
if len(parts) >= 2 {
|
|
hash := parts[0]
|
|
filename := parts[1]
|
|
// Remove leading * if present (indicates binary mode)
|
|
filename = strings.TrimPrefix(filename, "*")
|
|
|
|
if filename == tarballName {
|
|
expectedHash = strings.ToLower(hash)
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
if expectedHash == "" {
|
|
return fmt.Errorf("checksum not found for %s in checksum file", tarballName)
|
|
}
|
|
|
|
// Compute SHA256 of downloaded file
|
|
file, err := os.Open(tarballPath)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to open tarball for checksum: %w", err)
|
|
}
|
|
defer func() {
|
|
if closeErr := file.Close(); closeErr != nil {
|
|
log.Warn().Err(closeErr).Str("path", tarballPath).Msg("Failed to close tarball after checksum verification")
|
|
}
|
|
}()
|
|
|
|
hash := sha256.New()
|
|
if _, err := io.Copy(hash, file); err != nil {
|
|
return fmt.Errorf("failed to compute checksum: %w", err)
|
|
}
|
|
|
|
actualHash := hex.EncodeToString(hash.Sum(nil))
|
|
|
|
// Compare hashes
|
|
if actualHash != expectedHash {
|
|
checksumErr = fmt.Errorf("checksum mismatch: expected %s, got %s", expectedHash, actualHash)
|
|
log.Error().
|
|
Str("expected", expectedHash).
|
|
Str("actual", actualHash).
|
|
Msg("Checksum verification failed")
|
|
return checksumErr
|
|
}
|
|
|
|
log.Info().
|
|
Str("hash", actualHash).
|
|
Msg("Checksum verified successfully")
|
|
|
|
return nil
|
|
}
|
|
|
|
// extractTarball extracts a gzipped tarball
|
|
func (m *Manager) extractTarball(src, dest string) error {
|
|
file, err := os.Open(src)
|
|
if err != nil {
|
|
return fmt.Errorf("open tarball %q: %w", src, err)
|
|
}
|
|
defer func() {
|
|
if closeErr := file.Close(); closeErr != nil {
|
|
log.Warn().Err(closeErr).Str("path", src).Msg("Failed to close tarball file")
|
|
}
|
|
}()
|
|
|
|
gzr, err := gzip.NewReader(file)
|
|
if err != nil {
|
|
return fmt.Errorf("open gzip reader for %q: %w", src, err)
|
|
}
|
|
defer func() {
|
|
if closeErr := gzr.Close(); closeErr != nil {
|
|
log.Warn().Err(closeErr).Str("path", src).Msg("Failed to close gzip reader")
|
|
}
|
|
}()
|
|
|
|
tr := tar.NewReader(gzr)
|
|
|
|
for {
|
|
header, err := tr.Next()
|
|
if err == io.EOF {
|
|
break
|
|
}
|
|
if err != nil {
|
|
return fmt.Errorf("read tarball entry from %q: %w", src, err)
|
|
}
|
|
|
|
// Sanitize the path to prevent directory traversal attacks
|
|
cleanName := filepath.Clean(header.Name)
|
|
|
|
// Check for path traversal attempts
|
|
if strings.Contains(cleanName, "..") || filepath.IsAbs(cleanName) {
|
|
return fmt.Errorf("unsafe path in archive: %s", header.Name)
|
|
}
|
|
|
|
// Ensure the target path is within the destination directory
|
|
target := filepath.Join(dest, cleanName)
|
|
if !strings.HasPrefix(target, filepath.Clean(dest)+string(os.PathSeparator)) && target != filepath.Clean(dest) {
|
|
return fmt.Errorf("path escapes destination directory: %s", header.Name)
|
|
}
|
|
|
|
switch header.Typeflag {
|
|
case tar.TypeDir:
|
|
if err := os.MkdirAll(target, 0755); err != nil {
|
|
return fmt.Errorf("create archive directory %q: %w", target, err)
|
|
}
|
|
case tar.TypeReg:
|
|
if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil {
|
|
return fmt.Errorf("create parent directory for archive file %q: %w", target, err)
|
|
}
|
|
|
|
out, err := os.OpenFile(target, os.O_CREATE|os.O_RDWR, os.FileMode(header.Mode))
|
|
if err != nil {
|
|
return fmt.Errorf("open archive target file %q: %w", target, err)
|
|
}
|
|
|
|
if _, err := io.Copy(out, tr); err != nil {
|
|
if closeErr := out.Close(); closeErr != nil {
|
|
return errors.Join(
|
|
fmt.Errorf("write archive file %q: %w", target, err),
|
|
fmt.Errorf("close archive file %q after write failure: %w", target, closeErr),
|
|
)
|
|
}
|
|
return fmt.Errorf("write archive file %q: %w", target, err)
|
|
}
|
|
if closeErr := out.Close(); closeErr != nil {
|
|
return fmt.Errorf("close archive file %q: %w", target, closeErr)
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// createBackup creates a backup of the current installation.
|
|
func (m *Manager) createBackup(ctx context.Context) (string, error) {
|
|
timestamp := time.Now().Format("20060102-150405")
|
|
if err := m.pruneRetainedUpdateBackups(ctx); err != nil {
|
|
log.Warn().Err(err).Msg("Failed to prune retained update backups before creating a new backup")
|
|
}
|
|
|
|
backupBytes, err := estimateUpdateBackupBytes()
|
|
if err != nil {
|
|
return "", fmt.Errorf("estimate backup size: %w", err)
|
|
}
|
|
requiredBytes := backupBytes + updateBackupSafetyBytes
|
|
|
|
backupRoot, err := selectUpdateRootWithSpace(ctx, managedUpdateBackupRoots(), requiredBytes)
|
|
if err != nil {
|
|
return "", fmt.Errorf("select backup directory: %w", err)
|
|
}
|
|
|
|
backupDir := managedUpdateBackupPath(backupRoot, timestamp)
|
|
if err := os.MkdirAll(backupDir, 0755); err != nil {
|
|
return "", fmt.Errorf("create backup directory %q: %w", backupDir, err)
|
|
}
|
|
|
|
// Backup important directories
|
|
dirsToBackup := []string{"data", "config"}
|
|
pulseDir := os.Getenv("PULSE_INSTALL_DIR")
|
|
if pulseDir == "" {
|
|
pulseDir = "/opt/pulse"
|
|
}
|
|
|
|
for _, dir := range dirsToBackup {
|
|
src := filepath.Join(pulseDir, dir)
|
|
dest := filepath.Join(backupDir, dir)
|
|
|
|
if _, err := os.Stat(src); err == nil {
|
|
if err := m.copyDirSafe(src, dest); err != nil {
|
|
log.Warn().Str("dir", dir).Err(err).Msg("Failed to backup directory")
|
|
}
|
|
}
|
|
}
|
|
|
|
// Backup .env file
|
|
envSrc := filepath.Join(pulseDir, ".env")
|
|
if _, err := os.Stat(envSrc); err == nil {
|
|
envDest := filepath.Join(backupDir, ".env")
|
|
if err := m.copyFileSafe(envSrc, envDest); err != nil {
|
|
log.Warn().Err(err).Msg("Failed to backup .env file")
|
|
}
|
|
}
|
|
|
|
// Backup the pulse binary itself
|
|
binaryPath, err := os.Executable()
|
|
if err == nil {
|
|
binaryDest := filepath.Join(backupDir, "pulse")
|
|
if err := m.copyFileSafe(binaryPath, binaryDest); err != nil {
|
|
log.Warn().Err(err).Msg("Failed to backup pulse binary")
|
|
} else {
|
|
log.Info().Str("binary", binaryPath).Msg("Backed up pulse binary")
|
|
}
|
|
}
|
|
|
|
// Backup VERSION file if it exists
|
|
versionSrc := filepath.Join(pulseDir, "VERSION")
|
|
if _, err := os.Stat(versionSrc); err == nil {
|
|
versionDest := filepath.Join(backupDir, "VERSION")
|
|
if err := m.copyFileSafe(versionSrc, versionDest); err != nil {
|
|
log.Warn().Err(err).Msg("Failed to backup VERSION file")
|
|
}
|
|
}
|
|
|
|
if err := m.pruneRetainedUpdateBackups(ctx); err != nil {
|
|
log.Warn().Err(err).Msg("Failed to prune retained update backups after creating a new backup")
|
|
}
|
|
|
|
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 := os.Getenv("PULSE_INSTALL_DIR")
|
|
if pulseDir == "" {
|
|
pulseDir = "/opt/pulse"
|
|
}
|
|
|
|
// Restore directories
|
|
dirsToRestore := []string{"data", "config"}
|
|
for _, dir := range dirsToRestore {
|
|
src := filepath.Join(backupDir, dir)
|
|
dest := filepath.Join(pulseDir, dir)
|
|
|
|
if _, err := os.Stat(src); err == nil {
|
|
// Remove existing directory first
|
|
if err := os.RemoveAll(dest); err != nil {
|
|
return fmt.Errorf("failed to remove existing %s: %w", dir, err)
|
|
}
|
|
if err := m.copyDirSafe(src, dest); err != nil {
|
|
return fmt.Errorf("failed to restore %s: %w", dir, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Restore .env
|
|
envSrc := filepath.Join(backupDir, ".env")
|
|
if _, err := os.Stat(envSrc); err == nil {
|
|
envDest := filepath.Join(pulseDir, ".env")
|
|
if err := m.copyFileSafe(envSrc, envDest); err != nil {
|
|
return fmt.Errorf("failed to restore .env: %w", err)
|
|
}
|
|
}
|
|
|
|
// Restore the pulse binary if it exists in backup
|
|
binarySrc := filepath.Join(backupDir, "pulse")
|
|
if _, err := os.Stat(binarySrc); err == nil {
|
|
binaryPath, err := os.Executable()
|
|
if err == nil {
|
|
// Create temp copy first, then atomic rename
|
|
tempBinary := binaryPath + ".restored"
|
|
if err := m.copyFileSafe(binarySrc, tempBinary); err != nil {
|
|
return fmt.Errorf("failed to restore pulse binary: %w", err)
|
|
}
|
|
if err := os.Chmod(tempBinary, 0755); err != nil {
|
|
return fmt.Errorf("failed to set binary permissions: %w", err)
|
|
}
|
|
if err := os.Rename(tempBinary, binaryPath); err != nil {
|
|
return fmt.Errorf("failed to replace binary: %w", err)
|
|
}
|
|
log.Info().Str("binary", binaryPath).Msg("Restored pulse binary")
|
|
}
|
|
}
|
|
|
|
// Restore VERSION file if it exists in backup
|
|
versionSrc := filepath.Join(backupDir, "VERSION")
|
|
if _, err := os.Stat(versionSrc); err == nil {
|
|
versionDest := filepath.Join(pulseDir, "VERSION")
|
|
if err := m.copyFileSafe(versionSrc, versionDest); err != nil {
|
|
log.Warn().Err(err).Msg("Failed to restore VERSION file")
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// applyUpdateFiles copies update files to the installation directory
|
|
// locateExtractedPulseBinary finds the pulse binary inside an extracted
|
|
// update, checking both the legacy root layout and the bin/ layout.
|
|
func locateExtractedPulseBinary(extractDir string) (string, error) {
|
|
pulseBinary := filepath.Join(extractDir, "pulse")
|
|
if _, err := os.Stat(pulseBinary); err == nil {
|
|
return pulseBinary, nil
|
|
}
|
|
pulseBinary = filepath.Join(extractDir, "bin", "pulse")
|
|
if _, err := os.Stat(pulseBinary); err != nil {
|
|
return "", fmt.Errorf("pulse binary not found in extract (checked both / and /bin/): %w", err)
|
|
}
|
|
return pulseBinary, nil
|
|
}
|
|
|
|
func (m *Manager) applyUpdateFiles(extractDir string) error {
|
|
pulseBinary, err := locateExtractedPulseBinary(extractDir)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Detect where the current binary is running from
|
|
binaryPath, err := os.Executable()
|
|
if err != nil {
|
|
// Fallback to default location
|
|
binaryPath = "/usr/local/bin/pulse"
|
|
}
|
|
|
|
// Copy the pulse binary to a temporary location first, then move atomically
|
|
tempBinary := binaryPath + ".new"
|
|
cmd := exec.Command("cp", pulseBinary, tempBinary)
|
|
if err := cmd.Run(); err != nil {
|
|
return fmt.Errorf("failed to copy pulse binary: %w", err)
|
|
}
|
|
|
|
// Make it executable
|
|
if err := os.Chmod(tempBinary, 0755); err != nil {
|
|
return fmt.Errorf("failed to set permissions: %w", err)
|
|
}
|
|
|
|
// Atomically replace the old binary with the new one
|
|
if err := os.Rename(tempBinary, binaryPath); err != nil {
|
|
// If rename fails (cross-device), try mv command
|
|
cmd = exec.Command("mv", "-f", tempBinary, binaryPath)
|
|
if err := cmd.Run(); err != nil {
|
|
return fmt.Errorf("failed to replace pulse binary: %w", err)
|
|
}
|
|
}
|
|
|
|
// Frontend is now embedded in the binary as of v4.2.2+
|
|
// No need to copy frontend files separately
|
|
// The new binary contains everything needed
|
|
|
|
// Copy VERSION file if it exists (to both locations for compatibility)
|
|
versionSrc := filepath.Join(extractDir, "VERSION")
|
|
if _, err := os.Stat(versionSrc); err == nil {
|
|
// Copy to /opt/pulse
|
|
cmd = exec.Command("cp", versionSrc, "/opt/pulse/VERSION")
|
|
if err := cmd.Run(); err != nil {
|
|
log.Debug().Err(err).Msg("Failed to copy VERSION to /opt/pulse")
|
|
}
|
|
|
|
// Copy to binary directory
|
|
binaryDir := filepath.Dir(binaryPath)
|
|
cmd = exec.Command("cp", versionSrc, filepath.Join(binaryDir, "VERSION"))
|
|
if err := cmd.Run(); err != nil {
|
|
log.Warn().Err(err).Msg("Failed to copy VERSION file")
|
|
}
|
|
}
|
|
|
|
// Deploy agent installation scripts from tarball
|
|
scriptsDir := filepath.Join(extractDir, "scripts")
|
|
if _, err := os.Stat(scriptsDir); err == nil {
|
|
destScriptsDir := "/opt/pulse/scripts"
|
|
if err := os.MkdirAll(destScriptsDir, 0755); err != nil {
|
|
log.Warn().Err(err).Msg("Failed to create scripts directory")
|
|
} else {
|
|
// List of agent scripts to deploy
|
|
agentScripts := []string{
|
|
"install-container-agent.sh",
|
|
"install-docker.sh",
|
|
"install.sh",
|
|
"install.ps1",
|
|
}
|
|
|
|
deployed := 0
|
|
for _, script := range agentScripts {
|
|
srcPath := filepath.Join(scriptsDir, script)
|
|
if _, err := os.Stat(srcPath); err == nil {
|
|
destPath := filepath.Join(destScriptsDir, script)
|
|
cmd = exec.Command("cp", srcPath, destPath)
|
|
if err := cmd.Run(); err != nil {
|
|
log.Warn().Err(err).Str("script", script).Msg("Failed to copy agent script")
|
|
continue
|
|
}
|
|
if err := os.Chmod(destPath, 0755); err != nil {
|
|
log.Warn().Err(err).Str("script", script).Msg("Failed to set script permissions")
|
|
}
|
|
deployed++
|
|
}
|
|
}
|
|
if deployed > 0 {
|
|
log.Info().Int("count", deployed).Msg("Deployed agent installation scripts")
|
|
}
|
|
}
|
|
}
|
|
|
|
// Deploy agent binaries from tarball (for serving to remote hosts)
|
|
binDir := filepath.Join(extractDir, "bin")
|
|
if _, err := os.Stat(binDir); err == nil {
|
|
destBinDir := "/opt/pulse/bin"
|
|
if err := os.MkdirAll(destBinDir, 0755); err != nil {
|
|
log.Warn().Err(err).Msg("Failed to create bin directory")
|
|
} else {
|
|
// Copy agent binaries (pulse-agent-* artifacts)
|
|
entries, err := os.ReadDir(binDir)
|
|
if err == nil {
|
|
agentBinariesDeployed := 0
|
|
for _, entry := range entries {
|
|
name := entry.Name()
|
|
// Skip the main pulse binary (already handled above) and directories
|
|
if entry.IsDir() || name == "pulse" {
|
|
continue
|
|
}
|
|
// Copy agent binaries
|
|
if strings.HasPrefix(name, "pulse-agent-") {
|
|
srcPath, err := securityutil.JoinStorageLeaf(binDir, name)
|
|
if err != nil {
|
|
log.Warn().Err(err).Str("binary", name).Msg("Skipping invalid agent binary path")
|
|
continue
|
|
}
|
|
destPath, err := securityutil.JoinStorageLeaf(destBinDir, name)
|
|
if err != nil {
|
|
log.Warn().Err(err).Str("binary", name).Msg("Skipping invalid destination binary path")
|
|
continue
|
|
}
|
|
cmd = exec.Command("cp", "-a", srcPath, destPath)
|
|
if err := cmd.Run(); err != nil {
|
|
log.Warn().Err(err).Str("binary", name).Msg("Failed to copy agent binary")
|
|
continue
|
|
}
|
|
// Set executable permission (skip for symlinks)
|
|
if info, err := os.Lstat(destPath); err == nil && info.Mode()&os.ModeSymlink == 0 {
|
|
if err := os.Chmod(destPath, 0755); err != nil {
|
|
log.Warn().Err(err).Str("binary", name).Msg("Failed to set binary permissions")
|
|
}
|
|
}
|
|
agentBinariesDeployed++
|
|
}
|
|
}
|
|
if agentBinariesDeployed > 0 {
|
|
log.Info().Int("count", agentBinariesDeployed).Msg("Deployed agent binaries")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Set ownership if /opt/pulse exists
|
|
if _, err := os.Stat("/opt/pulse"); err == nil {
|
|
cmd = exec.Command("chown", "-R", "pulse:pulse", "/opt/pulse")
|
|
if err := cmd.Run(); err != nil {
|
|
log.Warn().Err(err).Msg("Failed to set ownership")
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// updateStatus updates the current status
|
|
func (m *Manager) updateStatus(status string, progress int, message string, err ...error) {
|
|
m.statusMu.Lock()
|
|
m.status = UpdateStatus{
|
|
Status: status,
|
|
Progress: progress,
|
|
Message: message,
|
|
UpdatedAt: time.Now().Format(time.RFC3339),
|
|
}
|
|
// If error provided, sanitize and add to status
|
|
if len(err) > 0 && err[0] != nil {
|
|
m.status.Error = sanitizeError(err[0])
|
|
}
|
|
statusCopy := m.status
|
|
m.statusMu.Unlock()
|
|
|
|
m.lifecycleMu.RLock()
|
|
if m.closed {
|
|
m.lifecycleMu.RUnlock()
|
|
if delay := statusDelayForStage(status); delay > 0 {
|
|
time.Sleep(delay)
|
|
}
|
|
return
|
|
}
|
|
// Send to progress channel (non-blocking) for WebSocket compatibility
|
|
m.progressMu.RLock()
|
|
if !m.closed {
|
|
select {
|
|
case m.progressChan <- statusCopy:
|
|
default:
|
|
}
|
|
}
|
|
m.progressMu.RUnlock()
|
|
|
|
// Broadcast to SSE clients
|
|
if m.sseBroadcast != nil {
|
|
m.sseBroadcast.Broadcast(statusCopy)
|
|
}
|
|
m.lifecycleMu.RUnlock()
|
|
|
|
if delay := statusDelayForStage(status); delay > 0 {
|
|
time.Sleep(delay)
|
|
}
|
|
}
|
|
|
|
// sseHeartbeatLoop sends periodic heartbeats to SSE clients
|
|
func (m *Manager) sseHeartbeatLoop() {
|
|
defer m.heartbeatWg.Done()
|
|
|
|
ticker := time.NewTicker(30 * time.Second)
|
|
defer ticker.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-m.shutdownCh:
|
|
return
|
|
case <-ticker.C:
|
|
m.lifecycleMu.RLock()
|
|
broadcaster := m.sseBroadcast
|
|
m.lifecycleMu.RUnlock()
|
|
if broadcaster != nil {
|
|
broadcaster.SendHeartbeat()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// sanitizeError removes potentially sensitive information from error messages
|
|
func sanitizeError(err error) string {
|
|
if err == nil {
|
|
return ""
|
|
}
|
|
|
|
errMsg := err.Error()
|
|
|
|
// Cap length to prevent extremely long error messages
|
|
maxLen := 500
|
|
if len(errMsg) > maxLen {
|
|
errMsg = errMsg[:maxLen] + "..."
|
|
}
|
|
|
|
return errMsg
|
|
}
|
|
|
|
func statusDelayForStage(status string) time.Duration {
|
|
delay := configuredStageDelay()
|
|
if delay == 0 {
|
|
return 0
|
|
}
|
|
|
|
switch status {
|
|
case "downloading", "verifying", "extracting", "backing-up", "applying":
|
|
return delay
|
|
default:
|
|
return 0
|
|
}
|
|
}
|
|
|
|
func configuredStageDelay() time.Duration {
|
|
stageDelayOnce.Do(func() {
|
|
value := strings.TrimSpace(os.Getenv("PULSE_UPDATE_STAGE_DELAY_MS"))
|
|
if value == "" {
|
|
return
|
|
}
|
|
ms, err := strconv.Atoi(value)
|
|
if err != nil || ms <= 0 {
|
|
log.Warn().Str("value", value).Msg("Invalid PULSE_UPDATE_STAGE_DELAY_MS, ignoring")
|
|
return
|
|
}
|
|
stageDelayValue = time.Duration(ms) * time.Millisecond
|
|
})
|
|
|
|
return stageDelayValue
|
|
}
|
|
|
|
func resolveUpdateDataDir() string {
|
|
return config.ResolveRuntimeDataDir("")
|
|
}
|
|
|
|
type retainedUpdateBackup struct {
|
|
Path string
|
|
ModTime time.Time
|
|
}
|
|
|
|
func managedUpdateBackupRoots() []string {
|
|
seen := make(map[string]struct{}, 2)
|
|
roots := make([]string, 0, 2)
|
|
for _, root := range []string{resolveUpdateDataDir(), "/tmp"} {
|
|
cleaned := strings.TrimSpace(filepath.Clean(root))
|
|
if cleaned == "" {
|
|
continue
|
|
}
|
|
if _, exists := seen[cleaned]; exists {
|
|
continue
|
|
}
|
|
seen[cleaned] = struct{}{}
|
|
roots = append(roots, cleaned)
|
|
}
|
|
return roots
|
|
}
|
|
|
|
func managedUpdateBackupPath(root string, timestamp string) string {
|
|
cleaned := filepath.Clean(strings.TrimSpace(root))
|
|
if cleaned == "/tmp" {
|
|
return filepath.Join(cleaned, fmt.Sprintf("pulse-backup-%s", timestamp))
|
|
}
|
|
return filepath.Join(cleaned, fmt.Sprintf("backup-%s", timestamp))
|
|
}
|
|
|
|
func managedUpdateBackupPrefix(root string) string {
|
|
if filepath.Clean(strings.TrimSpace(root)) == "/tmp" {
|
|
return "pulse-backup-"
|
|
}
|
|
return "backup-"
|
|
}
|
|
|
|
func managedUpdateTempRoots() []string {
|
|
return []string{resolveUpdateDataDir(), "/tmp", "."}
|
|
}
|
|
|
|
func formatUpdateBytes(bytes int64) string {
|
|
if bytes <= 0 {
|
|
return "0 B"
|
|
}
|
|
units := []string{"B", "KiB", "MiB", "GiB", "TiB"}
|
|
value := float64(bytes)
|
|
unit := units[0]
|
|
for i := 1; i < len(units) && value >= 1024; i++ {
|
|
value /= 1024
|
|
unit = units[i]
|
|
}
|
|
if unit == "B" {
|
|
return fmt.Sprintf("%d %s", bytes, unit)
|
|
}
|
|
return fmt.Sprintf("%.1f %s", value, unit)
|
|
}
|
|
|
|
func resolveDiskUsagePath(path string) string {
|
|
current := filepath.Clean(strings.TrimSpace(path))
|
|
if current == "" {
|
|
return "."
|
|
}
|
|
for {
|
|
if _, err := os.Stat(current); err == nil {
|
|
return current
|
|
}
|
|
parent := filepath.Dir(current)
|
|
if parent == current {
|
|
return current
|
|
}
|
|
current = parent
|
|
}
|
|
}
|
|
|
|
func updatePathFreeBytes(ctx context.Context, path string) (int64, error) {
|
|
usagePath := resolveDiskUsagePath(path)
|
|
usage, err := updateDiskUsage(ctx, usagePath)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("inspect disk usage for %q: %w", usagePath, err)
|
|
}
|
|
return int64(usage.Free), nil
|
|
}
|
|
|
|
func ensureUpdatePathHasFreeSpace(ctx context.Context, path string, requiredBytes int64, activity string) error {
|
|
if requiredBytes <= 0 {
|
|
return nil
|
|
}
|
|
freeBytes, err := updatePathFreeBytes(ctx, path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if freeBytes >= requiredBytes {
|
|
return nil
|
|
}
|
|
return fmt.Errorf(
|
|
"%s needs %s free at %s, only %s available",
|
|
activity,
|
|
formatUpdateBytes(requiredBytes),
|
|
resolveDiskUsagePath(path),
|
|
formatUpdateBytes(freeBytes),
|
|
)
|
|
}
|
|
|
|
func selectUpdateRootWithSpace(ctx context.Context, candidates []string, requiredBytes int64) (string, error) {
|
|
type insufficientSpaceCandidate struct {
|
|
Path string
|
|
Free int64
|
|
Err error
|
|
}
|
|
|
|
seen := make(map[string]struct{}, len(candidates))
|
|
failures := make([]insufficientSpaceCandidate, 0, len(candidates))
|
|
|
|
for _, candidate := range candidates {
|
|
cleaned := strings.TrimSpace(filepath.Clean(candidate))
|
|
if cleaned == "" {
|
|
continue
|
|
}
|
|
if _, exists := seen[cleaned]; exists {
|
|
continue
|
|
}
|
|
seen[cleaned] = struct{}{}
|
|
|
|
if cleaned != "." {
|
|
if err := os.MkdirAll(cleaned, 0755); err != nil {
|
|
failures = append(failures, insufficientSpaceCandidate{Path: cleaned, Err: err})
|
|
continue
|
|
}
|
|
}
|
|
|
|
freeBytes, err := updatePathFreeBytes(ctx, cleaned)
|
|
if err != nil {
|
|
failures = append(failures, insufficientSpaceCandidate{Path: cleaned, Err: err})
|
|
continue
|
|
}
|
|
if freeBytes >= requiredBytes {
|
|
return cleaned, nil
|
|
}
|
|
failures = append(failures, insufficientSpaceCandidate{Path: cleaned, Free: freeBytes})
|
|
}
|
|
|
|
parts := make([]string, 0, len(failures))
|
|
for _, failure := range failures {
|
|
if failure.Err != nil {
|
|
parts = append(parts, fmt.Sprintf("%s unavailable (%v)", failure.Path, failure.Err))
|
|
continue
|
|
}
|
|
parts = append(parts, fmt.Sprintf("%s has %s free", failure.Path, formatUpdateBytes(failure.Free)))
|
|
}
|
|
if len(parts) == 0 {
|
|
return "", fmt.Errorf("no writable update directories available")
|
|
}
|
|
return "", fmt.Errorf("need %s free, candidates: %s", formatUpdateBytes(requiredBytes), strings.Join(parts, "; "))
|
|
}
|
|
|
|
func (m *Manager) createUpdateTempDir(ctx context.Context, requiredBytes int64) (string, error) {
|
|
tempRoot, err := selectUpdateRootWithSpace(ctx, managedUpdateTempRoots(), requiredBytes)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
tempDir, err := os.MkdirTemp(tempRoot, "pulse-update-*")
|
|
if err != nil {
|
|
return "", fmt.Errorf("create temp directory in %q: %w", tempRoot, err)
|
|
}
|
|
return tempDir, nil
|
|
}
|
|
|
|
func estimatePathCopyBytes(path string) (int64, error) {
|
|
info, err := os.Lstat(path)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return 0, nil
|
|
}
|
|
return 0, err
|
|
}
|
|
if info.Mode()&os.ModeSymlink != 0 {
|
|
return 0, nil
|
|
}
|
|
if !info.IsDir() {
|
|
return info.Size(), nil
|
|
}
|
|
|
|
var total int64
|
|
err = filepath.Walk(path, func(current string, currentInfo os.FileInfo, walkErr error) error {
|
|
if walkErr != nil {
|
|
return walkErr
|
|
}
|
|
if currentInfo.Mode()&os.ModeSymlink != 0 {
|
|
if currentInfo.IsDir() {
|
|
return filepath.SkipDir
|
|
}
|
|
return nil
|
|
}
|
|
if currentInfo.IsDir() {
|
|
return nil
|
|
}
|
|
total += currentInfo.Size()
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return total, nil
|
|
}
|
|
|
|
func estimateUpdateBackupBytes() (int64, error) {
|
|
pulseDir := strings.TrimSpace(os.Getenv("PULSE_INSTALL_DIR"))
|
|
if pulseDir == "" {
|
|
pulseDir = "/opt/pulse"
|
|
}
|
|
|
|
paths := []string{
|
|
filepath.Join(pulseDir, "data"),
|
|
filepath.Join(pulseDir, "config"),
|
|
filepath.Join(pulseDir, ".env"),
|
|
filepath.Join(pulseDir, "VERSION"),
|
|
}
|
|
if binaryPath, err := os.Executable(); err == nil && strings.TrimSpace(binaryPath) != "" {
|
|
paths = append(paths, binaryPath)
|
|
}
|
|
|
|
var total int64
|
|
for _, path := range paths {
|
|
size, err := estimatePathCopyBytes(path)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("measure %q: %w", path, err)
|
|
}
|
|
total += size
|
|
}
|
|
return total, nil
|
|
}
|
|
|
|
func estimateTarballExtractBytes(path string) (int64, error) {
|
|
file, err := os.Open(path)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("open tarball %q: %w", path, err)
|
|
}
|
|
defer file.Close()
|
|
|
|
gzReader, err := gzip.NewReader(file)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("open gzip reader for %q: %w", path, err)
|
|
}
|
|
defer gzReader.Close()
|
|
|
|
tarReader := tar.NewReader(gzReader)
|
|
var total int64
|
|
for {
|
|
header, err := tarReader.Next()
|
|
if err == io.EOF {
|
|
break
|
|
}
|
|
if err != nil {
|
|
return 0, fmt.Errorf("walk tarball %q: %w", path, err)
|
|
}
|
|
switch header.Typeflag {
|
|
case tar.TypeReg, tar.TypeRegA, tar.TypeGNUSparse:
|
|
if header.Size > 0 {
|
|
total += header.Size
|
|
}
|
|
}
|
|
}
|
|
|
|
return total, nil
|
|
}
|
|
|
|
func (m *Manager) collectManagedUpdateBackups() ([]retainedUpdateBackup, error) {
|
|
backups := make([]retainedUpdateBackup, 0)
|
|
for _, root := range managedUpdateBackupRoots() {
|
|
entries, err := os.ReadDir(root)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
continue
|
|
}
|
|
return nil, fmt.Errorf("read backup root %q: %w", root, err)
|
|
}
|
|
prefix := managedUpdateBackupPrefix(root)
|
|
for _, entry := range entries {
|
|
if !entry.IsDir() || !strings.HasPrefix(entry.Name(), prefix) {
|
|
continue
|
|
}
|
|
fullPath, err := securityutil.JoinStorageLeaf(root, entry.Name())
|
|
if err != nil {
|
|
log.Debug().Err(err).Str("root", root).Str("entry", entry.Name()).Msg("Skipping invalid update backup leaf")
|
|
continue
|
|
}
|
|
info, err := entry.Info()
|
|
if err != nil {
|
|
continue
|
|
}
|
|
backups = append(backups, retainedUpdateBackup{
|
|
Path: fullPath,
|
|
ModTime: info.ModTime(),
|
|
})
|
|
}
|
|
}
|
|
|
|
sort.Slice(backups, func(i, j int) bool {
|
|
if backups[i].ModTime.Equal(backups[j].ModTime) {
|
|
return backups[i].Path > backups[j].Path
|
|
}
|
|
return backups[i].ModTime.After(backups[j].ModTime)
|
|
})
|
|
return backups, nil
|
|
}
|
|
|
|
func appendRetentionNote(notes string, prunedPath string) string {
|
|
retentionNote := fmt.Sprintf("Rollback backup pruned by retention: %s", prunedPath)
|
|
if strings.Contains(notes, retentionNote) {
|
|
return notes
|
|
}
|
|
trimmed := strings.TrimSpace(notes)
|
|
if trimmed == "" {
|
|
return retentionNote
|
|
}
|
|
return trimmed + "\n" + retentionNote
|
|
}
|
|
|
|
func (m *Manager) clearPrunedBackupHistoryReference(ctx context.Context, prunedPath string) {
|
|
if m.history == nil {
|
|
return
|
|
}
|
|
|
|
for _, entry := range m.history.ListEntries(HistoryFilter{}) {
|
|
if filepath.Clean(entry.BackupPath) != filepath.Clean(prunedPath) {
|
|
continue
|
|
}
|
|
if err := m.history.UpdateEntry(ctx, entry.EventID, func(existing *UpdateHistoryEntry) error {
|
|
existing.BackupPath = ""
|
|
existing.Notes = appendRetentionNote(existing.Notes, prunedPath)
|
|
return nil
|
|
}); err != nil {
|
|
log.Warn().
|
|
Err(err).
|
|
Str("event_id", entry.EventID).
|
|
Str("backup", prunedPath).
|
|
Msg("Failed to clear pruned backup reference from update history")
|
|
}
|
|
}
|
|
}
|
|
|
|
func (m *Manager) pruneRetainedUpdateBackups(ctx context.Context) error {
|
|
backups, err := m.collectManagedUpdateBackups()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if len(backups) <= maxRetainedUpdateBackups {
|
|
return nil
|
|
}
|
|
|
|
for _, backup := range backups[maxRetainedUpdateBackups:] {
|
|
if err := os.RemoveAll(backup.Path); err != nil {
|
|
return fmt.Errorf("remove retained backup %q: %w", backup.Path, err)
|
|
}
|
|
m.clearPrunedBackupHistoryReference(ctx, backup.Path)
|
|
log.Info().Str("path", backup.Path).Msg("Pruned retained update backup")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (m *Manager) cleanupOldUpdateArtifacts() {
|
|
m.cleanupOldTempDirs()
|
|
if err := m.pruneRetainedUpdateBackups(context.Background()); err != nil {
|
|
log.Debug().Err(err).Msg("Failed to prune retained update backups during startup cleanup")
|
|
}
|
|
}
|
|
|
|
// cleanupOldTempDirs removes old pulse-update-* temp directories from previous runs
|
|
func (m *Manager) cleanupOldTempDirs() {
|
|
// Check multiple locations where temp dirs might exist
|
|
dirsToCheck := []string{"/tmp", resolveUpdateDataDir(), "."}
|
|
|
|
for _, dir := range dirsToCheck {
|
|
entries, err := os.ReadDir(dir)
|
|
if err != nil {
|
|
continue // Directory not accessible, skip
|
|
}
|
|
|
|
for _, entry := range entries {
|
|
if !entry.IsDir() {
|
|
continue
|
|
}
|
|
|
|
// Check if it matches pulse-update-* pattern
|
|
if !strings.HasPrefix(entry.Name(), "pulse-update-") {
|
|
continue
|
|
}
|
|
|
|
fullPath, err := securityutil.JoinStorageLeaf(dir, entry.Name())
|
|
if err != nil {
|
|
log.Debug().Err(err).Str("dir", dir).Str("entry", entry.Name()).Msg("Skipping invalid temp directory leaf")
|
|
continue
|
|
}
|
|
info, err := os.Stat(fullPath)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
// Remove directories older than 24 hours
|
|
if time.Since(info.ModTime()) > 24*time.Hour {
|
|
if err := os.RemoveAll(fullPath); err != nil {
|
|
log.Debug().Err(err).Str("path", fullPath).Msg("Failed to cleanup old temp directory")
|
|
} else {
|
|
log.Info().Str("path", fullPath).Msg("Cleaned up old temp directory")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// copyFileSafe safely copies a file, skipping symlinks for security
|
|
func (m *Manager) copyFileSafe(src, dest string) error {
|
|
// Get file info and check if it's a symlink
|
|
info, err := os.Lstat(src)
|
|
if err != nil {
|
|
return fmt.Errorf("lstat source file %q: %w", src, err)
|
|
}
|
|
|
|
// Skip symlinks for security
|
|
if info.Mode()&os.ModeSymlink != 0 {
|
|
log.Warn().Str("file", src).Msg("Skipping symlink during backup/restore")
|
|
return nil
|
|
}
|
|
|
|
// Open source file
|
|
srcFile, err := os.Open(src)
|
|
if err != nil {
|
|
return fmt.Errorf("open source file %q: %w", src, err)
|
|
}
|
|
defer func() {
|
|
if closeErr := srcFile.Close(); closeErr != nil {
|
|
log.Warn().Err(closeErr).Str("path", src).Msg("Failed to close source file after copy")
|
|
}
|
|
}()
|
|
|
|
// Create destination file with same permissions
|
|
destFile, err := os.OpenFile(dest, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, info.Mode())
|
|
if err != nil {
|
|
return fmt.Errorf("open destination file %q: %w", dest, err)
|
|
}
|
|
defer func() {
|
|
if closeErr := destFile.Close(); closeErr != nil {
|
|
log.Warn().Err(closeErr).Str("path", dest).Msg("Failed to close destination file after copy")
|
|
}
|
|
}()
|
|
|
|
// Copy contents
|
|
if _, err := io.Copy(destFile, srcFile); err != nil {
|
|
return fmt.Errorf("copy %q to %q: %w", src, dest, err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// copyDirSafe recursively copies a directory, skipping symlinks for security
|
|
func (m *Manager) copyDirSafe(src, dest string) error {
|
|
// Get source directory info
|
|
srcInfo, err := os.Stat(src)
|
|
if err != nil {
|
|
return fmt.Errorf("stat source directory %q: %w", src, err)
|
|
}
|
|
|
|
// Create destination directory
|
|
if err := os.MkdirAll(dest, srcInfo.Mode()); err != nil {
|
|
return fmt.Errorf("create destination directory %q: %w", dest, err)
|
|
}
|
|
|
|
// Read source directory entries
|
|
entries, err := os.ReadDir(src)
|
|
if err != nil {
|
|
return fmt.Errorf("read source directory %q: %w", src, err)
|
|
}
|
|
|
|
for _, entry := range entries {
|
|
srcPath, err := securityutil.JoinStorageLeaf(src, entry.Name())
|
|
if err != nil {
|
|
log.Warn().Err(err).Str("dir", src).Str("entry", entry.Name()).Msg("Skipping invalid source path during backup/restore")
|
|
continue
|
|
}
|
|
destPath, err := securityutil.JoinStorageLeaf(dest, entry.Name())
|
|
if err != nil {
|
|
log.Warn().Err(err).Str("dir", dest).Str("entry", entry.Name()).Msg("Skipping invalid destination path during backup/restore")
|
|
continue
|
|
}
|
|
|
|
// Get file info (using Lstat to detect symlinks)
|
|
info, err := os.Lstat(srcPath)
|
|
if err != nil {
|
|
log.Warn().Str("path", srcPath).Err(err).Msg("Failed to stat file during copy")
|
|
continue
|
|
}
|
|
|
|
// Skip symlinks for security
|
|
if info.Mode()&os.ModeSymlink != 0 {
|
|
log.Warn().Str("path", srcPath).Msg("Skipping symlink during backup/restore")
|
|
continue
|
|
}
|
|
|
|
if entry.IsDir() {
|
|
// Recursively copy subdirectory
|
|
if err := m.copyDirSafe(srcPath, destPath); err != nil {
|
|
return fmt.Errorf("copy subdirectory %q: %w", srcPath, err)
|
|
}
|
|
} else {
|
|
// Copy file
|
|
if err := m.copyFileSafe(srcPath, destPath); err != nil {
|
|
log.Warn().Str("file", srcPath).Err(err).Msg("Failed to copy file")
|
|
continue
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// isPreV4Installation checks if this is a pre-v4 (Node.js based) installation
|
|
func isPreV4Installation() bool {
|
|
// Check for .env file (used by Node.js version)
|
|
if _, err := os.Stat("/opt/pulse/.env"); err == nil {
|
|
return true
|
|
}
|
|
|
|
// Note: pulse-backend.service is used by both v4 and pre-v4, so we can't use it as an indicator
|
|
// Only check for Node.js artifacts which are exclusive to pre-v4
|
|
|
|
// Check for Node.js artifacts
|
|
nodeArtifacts := []string{
|
|
"/opt/pulse/package.json",
|
|
"/opt/pulse/node_modules",
|
|
"/opt/pulse/server.js",
|
|
"/opt/pulse/backend",
|
|
"/opt/pulse/frontend",
|
|
}
|
|
|
|
for _, artifact := range nodeArtifacts {
|
|
if _, err := os.Stat(artifact); err == nil {
|
|
return true
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|