mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 10:35:51 +00:00
2761 lines
82 KiB
Go
2761 lines
82 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"`
|
|
// DockerUpdate carries digest-pinned image update commands from the
|
|
// license server download broker. Only set for Docker deployments of the
|
|
// compiled Pro binary, which cannot self-update in a container and must
|
|
// never be pointed at the community rcourtman/pulse image.
|
|
DockerUpdate *DockerUpdateCommands `json:"dockerUpdate,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
|
|
notesCache *ReleaseNotesInfo // release notes for notesCacheTag (nil when notesCacheMiss)
|
|
notesCacheTag string
|
|
notesCacheMiss bool
|
|
notesCacheTime time.Time
|
|
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
|
|
}
|